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.
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.
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' });
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}`,
}));
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>;
}
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);
});
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));
| Feature | effector | mobx | nanostores | recoil | zustand |
|---|---|---|---|---|---|
| Philosophy | Functional Reactive | Observable OOP | Minimalist Atoms | Atomic React State | Simple Hook Store |
| Boilerplate | Medium (Explicit events) | Low (Magic proxies) | Very Low | Medium (Atoms/Selectors) | Very Low |
| React Integration | Custom hooks (useUnit) | observer wrapper | Custom hooks (useStore) | Deep (Context/Suspense) | Native Hooks |
| Derived State | combine / map | computed getters | computed helper | selector | Manual / Helpers |
| Framework Agnostic | โ Yes | โ Yes | โ Yes (Best in class) | โ No (React only) | โ ๏ธ Partial |
| Learning Curve | Steep (FRP concepts) | Medium (Magic behavior) | Low | High (New concepts) | Very Low |
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.
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.
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.
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.
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.
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.
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.
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 }),
}))
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>
}
You can, but bear in mind that it will cause the component to update on every state change!
const state = useBearStore()
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),
)
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),
}))
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() })
},
}))
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
...
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)
...
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,
})
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.
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)
), [])
...
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.
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 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
}),
})),
)
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))
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 } }).
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, ... })
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)
...
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.
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.