effector, mobx, react-query, recoil, redux, redux-saga, redux-thunk, xstate, and zustand are libraries that help manage state and side effects in JavaScript applications, particularly in React. They address different aspects of state management: global state (redux, zustand, recoil, mobx, effector), asynchronous data fetching and caching (react-query), complex state machines (xstate), and middleware for handling side effects in Redux-based apps (redux-thunk, redux-saga). Each offers distinct mental models, APIs, and trade-offs around predictability, boilerplate, reactivity, and developer experience.
Managing state in modern JavaScript apps goes far beyond useState. The libraries in this comparison solve different problems: some handle local UI state, others manage server data, complex workflows, or global application state. Let’s break down how they work in practice.
redux – Predictable State ContainerRedux enforces a single source of truth with immutable updates via pure reducer functions. Every change is triggered by a plain action object.
// redux
import { createStore } from 'redux';
const initialState = { count: 0 };
const reducer = (state = initialState, action) => {
if (action.type === 'increment') return { count: state.count + 1 };
return state;
};
const store = createStore(reducer);
store.dispatch({ type: 'increment' });
zustand – Minimalist Hook-Based StoreZustand skips reducers and actions. You define state and methods together in a single hook-like function.
// zustand
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
// In component
const { count, increment } = useStore();
recoil – Atomic State GraphRecoil models state as atoms (units) and selectors (derived state). Components subscribe only to what they use.
// recoil
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';
const countAtom = atom({ key: 'count', default: 0 });
const doubledCount = selector({
key: 'doubledCount',
get: ({ get }) => get(countAtom) * 2
});
// In component
const [count, setCount] = useRecoilState(countAtom);
const double = useRecoilValue(doubledCount);
mobx – Transparent Reactive ProgrammingMobX wraps state in observables. When you mutate them, all observers (like React components) update automatically.
// mobx
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';
class Counter {
count = 0;
constructor() { makeAutoObservable(this); }
increment() { this.count += 1; }
}
const counter = new Counter();
// In component (must be observer)
const CounterComponent = observer(() => <div>{counter.count}</div>);
effector – Event-Driven Reactive SystemEffector uses stores (state), events (triggers), and effects (async operations). It’s framework-agnostic and composable.
// effector
import { createEvent, createStore, createEffect } from 'effector';
const increment = createEvent();
const $count = createStore(0).on(increment, (state) => state + 1);
// In React
import { useUnit } from 'effector/react';
const Counter = () => {
const [count, inc] = useUnit([$count, increment]);
return <button onClick={inc}>{count}</button>;
};
xstate – Explicit State MachinesXState defines states and allowed transitions. Invalid transitions are impossible by design.
// xstate
import { createMachine, interpret } from 'xstate';
const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: { on: { TOGGLE: 'active' } },
active: { on: { TOGGLE: 'inactive' } }
}
});
const service = interpret(toggleMachine).start();
service.send('TOGGLE'); // moves to 'active'
react-query – Server-State SynchronizationReact Query doesn’t manage UI state. It handles fetching, caching, and syncing data from APIs.
// react-query
import { useQuery, useMutation } from '@tanstack/react-query';
// Fetching
const { data, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(res => res.json())
});
// Mutating
const mutation = useMutation({
mutationFn: (newTodo) => fetch('/api/todos', { method: 'POST', body: newTodo }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] })
});
redux-thunk – Simple Async MiddlewareThunks let action creators return functions that dispatch actions asynchronously.
// redux-thunk
const fetchUser = (id) => async (dispatch) => {
dispatch({ type: 'FETCH_USER_START' });
try {
const user = await api.getUser(id);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (err) {
dispatch({ type: 'FETCH_USER_ERROR', payload: err });
}
};
// Dispatch
store.dispatch(fetchUser(123));
redux-saga – Complex Side Effect ManagementSagas use generator functions to describe side effects declaratively, making them highly testable.
// redux-saga
import { call, put, takeEvery } from 'redux-saga/effects';
function* fetchUser(action) {
try {
const user = yield call(api.getUser, action.payload.id);
yield put({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (err) {
yield put({ type: 'FETCH_USER_ERROR', payload: err });
}
}
function* watchFetchUser() {
yield takeEvery('FETCH_USER_REQUEST', fetchUser);
}
Not all state libraries handle async the same way:
react-query is purpose-built for async data. It manages loading states, retries, caching, and background updates automatically.redux-thunk is the simplest way to do async in Redux but offers no advanced control.redux-saga gives full control over complex async flows (e.g., race conditions, cancellation) but requires learning generators.effector has first-class createEffect for async with built-in pending/error states.mobx lets you write async logic directly in methods using async/await.zustand supports async in setters just like regular functions.xstate can invoke promises or callbacks during state transitions.recoil uses selectorFamily with async functions, but error/loading states must be managed manually.redux alone cannot handle async — it requires middleware like thunk or saga.recoil, react-query, and mobx-react-lite are React-specific.effector, xstate, redux, zustand (though commonly used with React) work anywhere.redux, mobx, and recoil require context providers at the root. zustand and effector do not.recoil, mobx, zustand, and effector avoid unnecessary re-renders by letting components subscribe to specific state slices.redux causes all connected components to re-render on any state change unless you use React.memo or createSelector.recoil selectors, mobx computed, and effector derived stores cache results until dependencies change.| Library | Boilerplate | Learning Curve |
|---|---|---|
zustand | Very Low | Low |
mobx | Low | Medium |
react-query | Low | Low |
recoil | Medium | Medium |
effector | Medium | Medium-High |
xstate | Medium | High |
redux (+RTK) | Medium | Medium |
redux-thunk | Low | Low |
redux-saga | High | High |
It’s common — and often wise — to use more than one:
redux + react-query: Use Redux for UI state and React Query for server state.zustand + xstate: Use Zustand for simple global state and XState for complex feature flows.mobx + react-query: MobX for client state, React Query for API data.Avoid mixing multiple global state managers (e.g., Redux + Zustand) unless you have a clear boundary.
| Library | Best For |
|---|---|
effector | Framework-agnostic reactive systems |
mobx | Mutable-style code with auto-tracking |
react-query | Server data caching and synchronization |
recoil | Fine-grained React state with derived values |
redux | Predictable, debuggable global state |
redux-saga | Complex, testable side effects in Redux |
redux-thunk | Simple async logic in Redux |
xstate | Visualizable, safe state transitions |
zustand | Lightweight, hook-based global state |
react-query — it solves 80% of async data problems out of the box.zustand is the sweet spot for most teams: minimal setup, no providers, great performance.redux with Redux Toolkit remains a solid choice.xstate to eliminate invalid states.redux-thunk for anything beyond basic API calls; prefer redux-saga or switch to a non-Redux solution if async logic grows.Choose based on your problem, not trends. Most apps don’t need all nine — pick one or two that match your actual complexity.
Choose effector if you want a reactive, event-driven architecture that works outside React and supports fine-grained updates with minimal re-renders. It’s well-suited for complex logic that needs to be decoupled from UI frameworks, but requires learning its unique concepts like stores, events, and effects.
Choose mobx if you prefer writing mutable-like code that automatically tracks dependencies and updates observers. It reduces boilerplate significantly and works well for medium-sized apps where performance isn’t bottlenecked by frequent small updates, but it can make debugging harder due to implicit reactivity.
Choose react-query when your app heavily relies on server-state (data from APIs) and you need built-in caching, background refetching, mutations, and optimistic updates. It’s not a global state manager for UI state, but excels at synchronizing client and server data with minimal manual effort.
Choose recoil if you’re building a React-only app and want atomic, selector-based state that scales from simple to complex without context bottlenecks. It integrates deeply with React’s concurrent features, but is tightly coupled to React and may add complexity for teams unfamiliar with its atom/selector model.
Choose redux if you need a predictable, centralized store with strict unidirectional data flow, time-travel debugging, and strong ecosystem support. It’s ideal for large teams or apps requiring auditability, but comes with significant boilerplate unless paired with modern tooling like Redux Toolkit.
Choose redux-saga when managing complex, long-running side effects (like websockets or retry logic) in a Redux app. It uses generator functions for testable, declarative control flow, but has a steep learning curve and adds considerable complexity for simple use cases.
Choose redux-thunk for basic async logic in Redux apps, like simple API calls that dispatch actions before and after. It’s lightweight and easy to learn, but lacks advanced features for complex workflows — making it suitable only for straightforward side effects.
Choose xstate when your feature involves explicit, visualizable state transitions (e.g., wizards, modals, or device control). Its finite state machine model prevents invalid states and improves maintainability for complex flows, but introduces overhead for simple CRUD-style state.
Choose zustand for a lightweight, hook-based global state solution with minimal boilerplate and no context providers. It’s ideal for most React apps needing shared state without Redux’s ceremony, offers great performance via selective subscriptions, and supports middleware and persistence out of the box.
Business logic with ease
Effector implements business logic with ease for Javascript apps (React/React Native/Vue/Svelte/Node.js/Vanilla), allows you to manage data flow in complex applications. Effector provides best TypeScript support out of the box.
You can use any package manager
npm add effector
React
To getting started read our article how to write React and Typescript application.
npm add effector effector-react
SolidJS
npm add effector effector-solid
Vue
npm add effector effector-vue
Svelte
Svelte works with effector out of the box, no additional packages needed. See word chain game application written with svelte and effector.
CDN
For additional information, guides and api reference visit our documentation site
effector to your project's home pageYou can try effector with online playground
Code sharing, Typescript and react supported out of the box. Playground repository
Use effector-logger for printing updates to console, displaying current store values with ui or connecting application to familiar redux devtools
Your support allows us to improve the developer experience 🧡.