zustand vs nanostores vs mobx vs recoil vs effector
State Management Architectures: Comparing Effector, MobX, Nanostores, Recoil, and Zustand
zustandnanostoresmobxrecoileffectorSimilar Packages:

State Management Architectures: Comparing Effector, MobX, Nanostores, Recoil, and Zustand

effector, mobx, nanostores, recoil, and zustand are all state management libraries for JavaScript applications, but they solve the problem of data flow and reactivity in fundamentally different ways. mobx relies on transparent reactivity using proxies to automatically track dependencies, making it feel magical but sometimes opaque. effector uses a strict functional reactive programming (FRP) model with explicit stores, events, and effects, prioritizing predictability and type safety. recoil introduces the concept of atoms and selectors to manage derived state within the React rendering cycle, deeply integrating with React's concurrent features. zustand offers a minimalistic, hook-based approach that bypasses context providers for a simple global store. nanostores focuses on being framework-agnostic and tiny, providing separate stores for different data types (like arrays or objects) with a focus on tree-shaking and performance.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
zustand62,175,83458,81095.2 kB72 months agoMIT
nanostores12,322,5287,62355 kB235 days agoMIT
mobx3,760,07428,2084.84 MB259 days agoMIT
recoil453,57919,4102.21 MB3204 years agoMIT
effector182,0984,8531.59 MB165a year agoMIT

State Management Architectures: Comparing Effector, MobX, Nanostores, Recoil, and Zustand

Choosing a state management library is one of the most critical architectural decisions in frontend development. The five libraries discussed here โ€” effector, mobx, nanostores, recoil, and zustand โ€” all aim to solve the same problem: keeping your UI in sync with your data. However, they take vastly different paths to get there. Some rely on magic proxies, others on strict functional pipelines, and some on minimal hooks. Let's dive into how they handle real-world engineering scenarios.

๐Ÿ—๏ธ Core Philosophy: How State is Defined

The way you define state sets the tone for your entire application architecture.

effector treats state as a set of explicit "stores" that change only via "events" or "effects". Nothing happens implicitly. You must define the trigger for every change.

import { createStore, createEvent } from 'effector';

// Define an event
const userLoggedIn = createEvent();

// Define a store that reacts to the event
const $user = createStore(null).on(userLoggedIn, (_, payload) => payload);

// Trigger the change
userLoggedIn({ id: 1, name: 'Alice' });

mobx uses observable objects. You define normal JavaScript classes or objects, and MobX makes them reactive behind the scenes using proxies.

import { makeAutoObservable } from 'mobx';

class UserStore {
  user = null;

  constructor() {
    makeAutoObservable(this);
  }

  login(payload) {
    // Direct mutation triggers updates
    this.user = payload;
  }
}

const store = new UserStore();
store.login({ id: 1, name: 'Alice' });

nanostores provides tiny, standalone stores. You create a store and explicitly set new values. It does not use proxies or complex reactivity graphs.

import { atom } from 'nanostores';

const $user = atom(null);

// Set new value explicitly
$user.set({ id: 1, name: 'Alice' });

recoil introduces "atoms" as the basic unit of state. These are defined outside components and can be read/written inside them using hooks.

import { atom, useRecoilState } from 'recoil';

const userState = atom({
  key: 'userState',
  default: null,
});

function Component() {
  const [user, setUser] = useRecoilState(userState);
  setUser({ id: 1, name: 'Alice' });
}

zustand creates a store using a simple function that returns state and actions. It combines state and logic in one place without needing providers.

import { create } from 'zustand';

const useUserStore = create((set) => ({
  user: null,
  login: (payload) => set({ user: payload }),
}));

// Usage in component
const { user, login } = useUserStore();
login({ id: 1, name: 'Alice' });

๐Ÿ”„ Derived State: Computing Values from Other State

Real apps rarely store just raw data; they need computed values (e.g., filtering a list, calculating totals). How each library handles this varies significantly.

effector uses "combines" or "maps" to create derived stores. These are pure functions that recalculate only when dependencies change.

import { combine } from 'effector';

const $firstName = createStore('Alice');
const $lastName = createStore('Smith');

// Derived store
const $fullName = combine($firstName, $lastName, (first, last) => `${first} ${last}`);

mobx uses "computed" getters. They look like normal properties but are cached and only re-run when observables they depend on change.

import { makeAutoObservable, computed } from 'mobx';

class NameStore {
  first = 'Alice';
  last = 'Smith';

  constructor() {
    makeAutoObservable(this);
  }

  get fullName() {
    return `${this.first} ${this.last}`;
  }
}

nanostores does not have built-in derived stores. You typically subscribe to multiple stores and compute the value manually or use helper libraries like nanostores/computed.

import { atom, computed } from 'nanostores';

const $first = atom('Alice');
const $last = atom('Smith');

// Using computed helper
const $fullName = computed([$first, $last], (first, last) => `${first} ${last}`);

recoil excels here with "selectors". Selectors can depend on atoms or other selectors and support asynchronous data fetching directly.

import { selector } from 'recoil';

const fullNameSelector = selector({
  key: 'fullNameSelector',
  get: ({ get }) => {
    const first = get(firstNameAtom);
    const last = get(lastNameAtom);
    return `${first} ${last}`;
  },
});

zustand usually handles derived state inside the store logic or via external libraries like zustand/compute. You can also compute it directly in the component.

import { create } from 'zustand';

const useNameStore = create((set, get) => ({
  first: 'Alice',
  last: 'Smith',
  // Computed via getter function
  getFullName: () => `${get().first} ${get().last}`,
}));

โšก Reactivity Model: Push vs Pull

How does the UI know when to update? This is where the "magic" vs "explicit" debate heats up.

effector pushes updates. When an event fires, the library calculates exactly which components need to re-render and updates them. It does not rely on React's context for propagation.

// Effector components subscribe directly to stores
import { useUnit } from 'effector-react';

function UserProfile() {
  const user = useUnit($user); // Subscribes only to $user changes
  return <div>{user?.name}</div>;
}

mobx also pushes updates but relies on React's observer pattern. Components wrapped in observer automatically re-render when any observable they read changes.

import { observer } from 'mobx-react-lite';

const UserProfile = observer(({ store }) => {
  // Automatically tracks access to store.user
  return <div>{store.user?.name}</div>;
});

nanostores pushes updates via a simple subscriber pattern. In React, you use a hook to subscribe. It is extremely lightweight and does not trigger unnecessary parent re-renders.

import { useStore } from '@nanostores/react';

function UserProfile() {
  const user = useStore($user); // Subscribes to atom
  return <div>{user?.name}</div>;
}

recoil integrates deeply with React's rendering cycle. It uses a context-like mechanism but optimizes renders so components only update when specific atoms they use change.

import { useRecoilValue } from 'recoil';

function UserProfile() {
  const user = useRecoilValue(userState); // Subscribes to atom
  return <div>{user?.name}</div>;
}

zustand uses a custom hook that subscribes to the store. By default, it re-renders the component whenever the store changes, but you can select specific slices to optimize performance.

import { create } from 'zustand';

function UserProfile() {
  // Select only the name slice to prevent unnecessary renders
  const name = useUserStore((state) => state.user?.name);
  return <div>{name}</div>;
}

๐Ÿงช Testing and Predictability

For large teams, knowing exactly how state changes is vital for debugging and testing.

effector is highly predictable. Since state only changes via explicit events, you can test logic by firing events and asserting store values. No hidden mutations.

// Test case
import { allSettled, fork } from 'effector';
import { userLoggedIn, $user } from './model';

test('login updates user store', async () => {
  const scope = fork();
  await allSettled(userLoggedIn, { scope, params: { id: 1 } });
  expect(scope.getState($user)).toEqual({ id: 1 });
});

mobx can be harder to test because mutations happen anywhere. You often need to wrap actions in runInAction or ensure you are mocking the observable structure correctly.

// Test case
import { runInAction } from 'mobx';

test('login updates user', () => {
  runInAction(() => {
    store.login({ id: 1 });
  });
  expect(store.user.id).toBe(1);
});

nanostores is simple to test because stores are just functions. You set values and check them. There is no complex internal graph to mock.

// Test case
import { $user } from './store';

test('set user', () => {
  $user.set({ id: 1 });
  expect($user.get()).toEqual({ id: 1 });
});

recoil requires wrapping tests in a RecoilRoot provider. Testing selectors can be tricky if they depend on async data or other complex selectors.

// Test case
import { renderHook, waitFor } from '@testing-library/react';
import { RecoilRoot, useRecoilValue } from 'recoil';

test('user loads', async () => {
  const { result } = renderHook(() => useRecoilValue(userState), {
    wrapper: RecoilRoot,
  });
  // Assert logic here
});

zustand is easy to test because the store is a plain function. You can reset it between tests or mock the implementation easily.

// Test case
import { useUserStore } from './store';

test('login sets user', () => {
  useUserStore.getState().login({ id: 1 });
  expect(useUserStore.getState().user.id).toBe(1);
});

๐ŸŒ Framework Agnosticism

Not all logic lives in React. Sometimes you need to share state logic with Node.js scripts, Vue apps, or vanilla JS modules.

effector is completely framework agnostic. The core logic (stores, events, effects) works in Node.js, React, Vue, or Svelte. Only the view bindings are framework-specific.

// Works in Node.js
import { $user } from './model';
$user.watch((user) => console.log('User updated in Node:', user));

mobx is also framework agnostic. You can use mobx in vanilla JS or with Vue/Angular. The observer wrapper is specific to React, but the core reactivity is not.

// Works in Node.js
import { reaction } from 'mobx';
reaction(() => store.user, (user) => console.log('User updated:', user));

nanostores is designed specifically to be tiny and framework agnostic. It has first-class support for React, Vue, Svelte, and Solid, with separate tiny packages for each.

// Works in Node.js
import { $user } from './store';
$user.listen((user) => console.log('User updated:', user));

recoil is tightly coupled to React. It relies on React's concurrent mode and Suspense. You cannot use Recoil logic outside of a React application.

// โŒ Cannot be used outside React
// Recoil atoms require a React context to function

zustand is mostly React-focused, but the core store creation is just a function. You can use the store outside React, but you lose the hook-based subscription mechanism.

// Works in Node.js (manual subscription)
import { useUserStore } from './store';
const unsub = useUserStore.subscribe((state) => console.log(state));

๐Ÿ“Š Summary: Key Differences

Featureeffectormobxnanostoresrecoilzustand
PhilosophyFunctional ReactiveObservable OOPMinimalist AtomsAtomic React StateSimple Hook Store
BoilerplateMedium (Explicit events)Low (Magic proxies)Very LowMedium (Atoms/Selectors)Very Low
React IntegrationCustom hooks (useUnit)observer wrapperCustom hooks (useStore)Deep (Context/Suspense)Native Hooks
Derived Statecombine / mapcomputed getterscomputed helperselectorManual / Helpers
Framework Agnosticโœ… Yesโœ… Yesโœ… Yes (Best in class)โŒ No (React only)โš ๏ธ Partial
Learning CurveSteep (FRP concepts)Medium (Magic behavior)LowHigh (New concepts)Very Low

๐Ÿ’ก The Big Picture

effector is the choice for architects who demand strict control and scalability. If your team values explicit data flows and needs to share logic across different platforms (web, mobile, backend), its functional approach pays off in the long run.

mobx remains a powerful tool for teams that prefer object-oriented patterns and want to write less glue code. It is great for rapid development but requires discipline to avoid hidden dependencies in large projects.

nanostores is the rising star for performance and flexibility. If bundle size matters, or if you are building a design system or micro-frontend architecture that spans multiple frameworks, this is the most future-proof option.

recoil offers unique capabilities for complex React apps, especially those leveraging Suspense. However, its tight coupling to React and experimental status in some areas means it is best suited for teams fully invested in the React ecosystem who need advanced derived state features.

zustand is the pragmatic winner for most standard React applications. It removes the friction of providers and context, offering a "just works" experience that scales surprisingly well for small to medium complexity.

Final Thought: There is no single "best" library. The right choice depends on your team's comfort with functional vs. object-oriented patterns, your need for framework agnosticism, and the complexity of your state interactions. For pure React simplicity, pick zustand. For architectural rigor, pick effector. For maximum flexibility and size efficiency, pick nanostores.

How to Choose: zustand vs nanostores vs mobx vs recoil vs effector

  • zustand:

    Choose zustand if you want a straightforward, unopinionated global store that works immediately with minimal setup. It is perfect for small to medium-sized projects, or for teams that find Redux too verbose and MobX too magical. Its hook-based API and lack of required providers make it easy to adopt incrementally without refactoring your entire app structure.

  • nanostores:

    Choose nanostores if you are building for performance-critical environments, need a tiny bundle size, or are working in a monorepo with multiple frameworks (React, Vue, Svelte). It is the best choice when you want zero framework lock-in and need fine-grained control over when and how components subscribe to state changes without heavy runtime overhead.

  • mobx:

    Choose mobx if you want to minimize boilerplate and prefer an object-oriented style where state changes trigger updates automatically. It works well for rapidly evolving prototypes or applications where developers want to write less code to connect state to views. However, be prepared for a steeper learning curve regarding its magic reactivity system and potential debugging challenges in very large codebases.

  • recoil:

    Choose recoil if your application has complex derived state logic that needs to be shared across many components without prop drilling, and you are fully committed to the React ecosystem. It shines when you need atomic state updates and want to leverage React Suspense for data fetching. Avoid it if you need to share state logic with non-React environments or prefer a simpler mental model.

  • effector:

    Choose effector if you need a robust, type-safe solution for complex business logic that extends beyond just the UI layer. It is ideal for large-scale applications where explicit data flow, rigorous testing, and separation of concerns are critical. Its functional approach prevents hidden side effects, making it perfect for teams that value architectural clarity over minimal boilerplate.

README for zustand

Build Status Build Size Version Downloads Discord Shield

A small, fast and scalable bearbones state-management solution using simplified flux principles. Has a comfy API based on hooks, isn't boilerplatey or opinionated.

Don't disregard it because it's cute. It has quite the claws, lots of time was spent dealing with common pitfalls, like the dreaded zombie child problem, react concurrency, and context loss between mixed renderers. It may be the one state-manager in the React space that gets all of these right.

You can try a live demo and read the docs.

npm install zustand

:warning: This readme is written for JavaScript users. If you are a TypeScript user, be sure to check out our TypeScript Usage section.

First create a store

Your store is a hook! You can put anything in it: primitives, objects, functions. State has to be updated immutably and the set function merges state to help it.

import { create } from 'zustand'

const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

Then bind your components, and that's it!

Use the hook anywhere, no providers are needed. Select your state and the component will re-render on changes.

function BearCounter() {
  const bears = useBearStore((state) => state.bears)
  return <h1>{bears} around here ...</h1>
}

function Controls() {
  const increasePopulation = useBearStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

Why zustand over redux?

Why zustand over context?

  • Less boilerplate
  • Renders components only on changes
  • Centralized, action-based state management

Recipes

Fetching everything

You can, but bear in mind that it will cause the component to update on every state change!

const state = useBearStore()

Selecting multiple state slices

It detects changes with strict-equality (old === new) by default, this is efficient for atomic state picks.

const nuts = useBearStore((state) => state.nuts)
const honey = useBearStore((state) => state.honey)

If you want to construct a single object with multiple state-picks inside, similar to redux's mapStateToProps, you can use useShallow to prevent unnecessary rerenders when the selector output does not change according to shallow equal.

import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'

const useBearStore = create((set) => ({
  nuts: 0,
  honey: 0,
  treats: {},
  // ...
}))

// Object pick, re-renders the component when either state.nuts or state.honey change
const { nuts, honey } = useBearStore(
  useShallow((state) => ({ nuts: state.nuts, honey: state.honey })),
)

// Array pick, re-renders the component when either state.nuts or state.honey change
const [nuts, honey] = useBearStore(
  useShallow((state) => [state.nuts, state.honey]),
)

// Mapped picks, re-renders the component when state.treats changes in order, count or keys
const treats = useBearStore(useShallow((state) => Object.keys(state.treats)))

For more control over re-rendering, you may provide any custom equality function (this example requires the use of createWithEqualityFn).

const treats = useBearStore(
  (state) => state.treats,
  (oldTreats, newTreats) => compare(oldTreats, newTreats),
)

Overwriting state

The set function has a second argument, false by default. Instead of merging, it will replace the state model. Be careful not to wipe out parts you rely on, like actions.

const useFishStore = create((set) => ({
  salmon: 1,
  tuna: 2,
  deleteEverything: () => set({}, true), // clears the entire store, actions included
  deleteTuna: () => set(({ tuna, ...rest }) => rest, true),
}))

Async actions

Just call set when you're ready, zustand doesn't care if your actions are async or not.

const useFishStore = create((set) => ({
  fishies: {},
  fetch: async (pond) => {
    const response = await fetch(pond)
    set({ fishies: await response.json() })
  },
}))

Read from state in actions

set allows fn-updates set(state => result), but you still have access to state outside of it through get.

const useSoundStore = create((set, get) => ({
  sound: 'grunt',
  action: () => {
    const sound = get().sound
    ...

Reading/writing state and reacting to changes outside of components

Sometimes you need to access state in a non-reactive way or act upon the store. For these cases, the resulting hook has utility functions attached to its prototype.

:warning: This technique is not recommended for adding state in React Server Components (typically in Next.js 13 and above). It can lead to unexpected bugs and privacy issues for your users. For more details, see #2200.

const useDogStore = create(() => ({ paw: true, snout: true, fur: true }))

// Getting non-reactive fresh state
const paw = useDogStore.getState().paw
// Listening to all changes, fires synchronously on every change
const unsub1 = useDogStore.subscribe(console.log)
// Updating state, will trigger listeners
useDogStore.setState({ paw: false })
// Unsubscribe listeners
unsub1()

// You can of course use the hook as you always would
function Component() {
  const paw = useDogStore((state) => state.paw)
  ...

Using subscribe with selector

If you need to subscribe with a selector, subscribeWithSelector middleware will help.

With this middleware subscribe accepts an additional signature:

subscribe(selector, callback, options?: { equalityFn, fireImmediately }): Unsubscribe
import { subscribeWithSelector } from 'zustand/middleware'
const useDogStore = create(
  subscribeWithSelector(() => ({ paw: true, snout: true, fur: true })),
)

// Listening to selected changes, in this case when "paw" changes
const unsub2 = useDogStore.subscribe((state) => state.paw, console.log)
// Subscribe also exposes the previous value
const unsub3 = useDogStore.subscribe(
  (state) => state.paw,
  (paw, previousPaw) => console.log(paw, previousPaw),
)
// Subscribe also supports an optional equality function
const unsub4 = useDogStore.subscribe(
  (state) => [state.paw, state.fur],
  console.log,
  { equalityFn: shallow },
)
// Subscribe and fire immediately
const unsub5 = useDogStore.subscribe((state) => state.paw, console.log, {
  fireImmediately: true,
})

Using zustand without React

Zustand core can be imported and used without the React dependency. The only difference is that the create function does not return a hook, but the API utilities.

import { createStore } from 'zustand/vanilla'

const store = createStore((set) => ...)
const { getState, setState, subscribe, getInitialState } = store

export default store

You can use a vanilla store with useStore hook available since v4.

import { useStore } from 'zustand'
import { vanillaStore } from './vanillaStore'

const useBoundStore = (selector) => useStore(vanillaStore, selector)

:warning: Note that middlewares that modify set or get are not applied to getState and setState.

Transient updates (for often occurring state-changes)

The subscribe function allows components to bind to a state-portion without forcing re-render on changes. Best combine it with useEffect for automatic unsubscribe on unmount. This can make a drastic performance impact when you are allowed to mutate the view directly.

const useScratchStore = create((set) => ({ scratches: 0, ... }))

const Component = () => {
  // Fetch initial state
  const scratchRef = useRef(useScratchStore.getState().scratches)
  // Connect to the store on mount, disconnect on unmount, catch state-changes in a reference
  useEffect(() => useScratchStore.subscribe(
    state => (scratchRef.current = state.scratches)
  ), [])
  ...

Sick of reducers and changing nested states? Use Immer!

Reducing nested structures is tiresome. Have you tried immer?

import { produce } from 'immer'

const useLushStore = create((set) => ({
  lush: { forest: { contains: { a: 'bear' } } },
  clearForest: () =>
    set(
      produce((state) => {
        state.lush.forest.contains = null
      }),
    ),
}))

const clearForest = useLushStore((state) => state.clearForest)
clearForest()

Alternatively, there are some other solutions.

Persist middleware

You can persist your store's data using any kind of storage.

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

const useFishStore = create(
  persist(
    (set, get) => ({
      fishes: 0,
      addAFish: () => set({ fishes: get().fishes + 1 }),
    }),
    {
      name: 'food-storage', // name of the item in the storage (must be unique)
      storage: createJSONStorage(() => sessionStorage), // (optional) by default, 'localStorage' is used
    },
  ),
)

See the full documentation for this middleware.

Immer middleware

Immer is available as middleware too.

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

const useBeeStore = create(
  immer((set) => ({
    bees: 0,
    addBees: (by) =>
      set((state) => {
        state.bees += by
      }),
  })),
)

Can't live without redux-like reducers and action types?

const types = { increase: 'INCREASE', decrease: 'DECREASE' }

const reducer = (state, { type, by = 1 }) => {
  switch (type) {
    case types.increase:
      return { grumpiness: state.grumpiness + by }
    case types.decrease:
      return { grumpiness: state.grumpiness - by }
  }
}

const useGrumpyStore = create((set) => ({
  grumpiness: 0,
  dispatch: (args) => set((state) => reducer(state, args)),
}))

const dispatch = useGrumpyStore((state) => state.dispatch)
dispatch({ type: types.increase, by: 2 })

Or, just use our redux-middleware. It wires up your main-reducer, sets the initial state, and adds a dispatch function to the state itself and the vanilla API.

import { redux } from 'zustand/middleware'

const useGrumpyStore = create(redux(reducer, initialState))

Redux devtools

Install the Redux DevTools Chrome extension to use the devtools middleware.

import { devtools } from 'zustand/middleware'

// Usage with a plain action store, it will log actions as "setState"
const usePlainStore = create(devtools((set) => ...))
// Usage with a redux store, it will log full action types
const useReduxStore = create(devtools(redux(reducer, initialState)))

One redux devtools connection for multiple stores

import { devtools } from 'zustand/middleware'

// Usage with a plain action store, it will log actions as "setState"
const usePlainStore1 = create(devtools((set) => ..., { name, store: storeName1 }))
const usePlainStore2 = create(devtools((set) => ..., { name, store: storeName2 }))
// Usage with a redux store, it will log full action types
const useReduxStore1 = create(devtools(redux(reducer, initialState)), { name, store: storeName3 })
const useReduxStore2 = create(devtools(redux(reducer, initialState)), { name, store: storeName4 })

Assigning different connection names will separate stores in redux devtools. This also helps group different stores into separate redux devtools connections.

devtools takes the store function as its first argument, optionally you can name the store or configure serialize options with a second argument.

Name store: devtools(..., {name: "MyStore"}), which will create a separate instance named "MyStore" in the devtools.

Serialize options: devtools(..., { serialize: { options: true } }).

Logging Actions

devtools will only log actions from each separated store unlike in a typical combined reducers redux store. See an approach to combining stores https://github.com/pmndrs/zustand/issues/163

You can log a specific action type for each set function by passing a third parameter:

const useBearStore = create(devtools((set) => ({
  ...
  eatFish: () => set(
    (prev) => ({ fishes: prev.fishes > 1 ? prev.fishes - 1 : 0 }),
    undefined,
    'bear/eatFish'
  ),
  ...

You can also log the action's type along with its payload:

  ...
  addFishes: (count) => set(
    (prev) => ({ fishes: prev.fishes + count }),
    undefined,
    { type: 'bear/addFishes', count, }
  ),
  ...

If an action type is not provided, it is defaulted to "anonymous". You can customize this default value by providing an anonymousActionType parameter:

devtools(..., { anonymousActionType: 'unknown', ... })

If you wish to disable devtools (on production for instance). You can customize this setting by providing the enabled parameter:

devtools(..., { enabled: false, ... })

React context

The store created with create doesn't require context providers. In some cases, you may want to use contexts for dependency injection or if you want to initialize your store with props from a component. Because the normal store is a hook, passing it as a normal context value may violate the rules of hooks.

The recommended method available since v4 is to use the vanilla store.

import { createContext, useContext } from 'react'
import { createStore, useStore } from 'zustand'

const store = createStore(...) // vanilla store without hooks

const StoreContext = createContext()

const App = () => (
  <StoreContext.Provider value={store}>
    ...
  </StoreContext.Provider>
)

const Component = () => {
  const store = useContext(StoreContext)
  const slice = useStore(store, selector)
  ...

TypeScript Usage

Basic typescript usage doesn't require anything special except for writing create<State>()(...) instead of create(...)...

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type {} from '@redux-devtools/extension' // required for devtools typing

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()(
  devtools(
    persist(
      (set) => ({
        bears: 0,
        increase: (by) => set((state) => ({ bears: state.bears + by })),
      }),
      {
        name: 'bear-storage',
      },
    ),
  ),
)

A more detailed TypeScript guide is here and there.

Best practices

Third-Party Libraries

Some users may want to extend Zustand's feature set which can be done using third-party libraries made by the community. For information regarding third-party libraries with Zustand, visit the doc.

Comparison with other libraries