formik, mobx, mobx-state-tree, react-query, recoil, redux, xstate, and zustand are libraries that address different aspects of state management and data synchronization in React applications. While redux, recoil, zustand, mobx, and mobx-state-tree focus primarily on managing client-side application state, react-query specializes in server-state management — handling data fetching, caching, synchronization, and background updates. formik is a form-handling library that manages local form state, validation, and submission. xstate provides a finite state machine and statechart implementation for modeling complex UI logic with predictable transitions. These tools vary significantly in mental model, API design, and scope, making them suitable for different architectural needs.
Managing state in React apps isn’t one-size-fits-all. Some libraries handle form inputs, others manage server data, and some orchestrate complex UI workflows. Let’s cut through the noise and compare how each tool solves real problems.
First, separate concerns: server state (data from APIs) and client state (UI state, form inputs, local preferences).
react-query is purpose-built for server state. It caches responses, auto-refetches when components mount, and handles mutations safely.
// react-query: Fetch and cache user data
import { useQuery, useMutation } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
});
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => queryClient.invalidateQueries(['user', userId])
});
if (isLoading) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
All other libraries (redux, zustand, etc.) manage client state. Mixing server data into them leads to cache invalidation headaches — let react-query handle that.
formik treats forms as isolated units. It tracks values, errors, touched fields, and submission state without polluting global stores.
// formik: Manage a login form
import { useFormik } from 'formik';
function LoginForm() {
const formik = useFormik({
initialValues: { email: '', password: '' },
validate: (values) => {
const errors = {};
if (!values.email) errors.email = 'Required';
return errors;
},
onSubmit: (values) => login(values)
});
return (
<form onSubmit={formik.handleSubmit}>
<input
name="email"
onChange={formik.handleChange}
value={formik.values.email}
/>
{formik.errors.email && <div>{formik.errors.email}</div>}
<button type="submit">Login</button>
</form>
);
}
For simple forms, React’s useState often suffices. For complex nested forms with dynamic fields, Formik’s structure pays off.
mobx lets you write mutable code that React re-renders automatically when observables change.
// mobx: Observable store
import { makeAutoObservable } from 'mobx';
class CounterStore {
count = 0;
constructor() {
makeAutoObservable(this);
}
increment() {
this.count++; // Mutate directly
}
}
const store = new CounterStore();
// In component
import { observer } from 'mobx-react-lite';
const Counter = observer(() => <div>{store.count}</div>);
mobx-state-tree adds structure to MobX with models, types, and actions:
// mobx-state-tree: Typed state tree
import { types } from 'mobx-state-tree';
const Todo = types.model({
id: types.identifier,
title: types.string,
done: false
}).actions(self => ({
toggle() {
self.done = !self.done; // Still mutable
}
}));
const Store = types.model({
todos: types.array(Todo)
});
redux enforces immutability via reducers:
// redux: Slice with Redux Toolkit
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
incremented: (state) => {
state.value += 1; // Draft state is mutable, but result is immutable
}
}
});
// In component
import { useSelector, useDispatch } from 'react-redux';
function Counter() {
const count = useSelector(state => state.counter.value);
const dispatch = useDispatch();
return <button onClick={() => dispatch(counterSlice.actions.incremented())}>{count}</button>;
}
zustand offers a middle ground — a mutable store internally, but accessed via hooks:
// zustand: Simple store
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 }))
}));
function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>{count}</button>;
}
recoil uses atoms (state units) and selectors (derived state):
// recoil: Atoms and selectors
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';
const countAtom = atom({ key: 'count', default: 0 });
const doubledCount = selector({
key: 'doubledCount',
get: ({ get }) => get(countAtom) * 2
});
function Counter() {
const [count, setCount] = useRecoilState(countAtom);
const double = useRecoilValue(doubledCount);
return <div>{count} → {double}</div>;
}
xstate models behavior as statecharts, preventing invalid transitions:
// xstate: Light switch machine
import { createMachine, interpret } from 'xstate';
const lightSwitchMachine = createMachine({
id: 'light',
initial: 'off',
states: {
off: { on: { TOGGLE: 'on' } },
on: { on: { TOGGLE: 'off' } }
}
});
// In component
import { useMachine } from '@xstate/react';
function LightSwitch() {
const [state, send] = useMachine(lightSwitchMachine);
return (
<button onClick={() => send('TOGGLE')}>
Light is {state.value}
</button>
);
}
This prevents bugs like "turning on an already-on light" in complex flows (e.g., checkout wizards).
react-query instead.react-query for charts, user info, notifications.zustand for theme, sidebar open/closed.xstate for step navigation (e.g., dates → guests → payment).formik for each step’s inputs.react-query for availability checks.mobx for cursor positions, undo history (mutable, reactive).react-query for document fetch/save, with WebSockets for live updates.| Library | Best For | Mental Model | Key Strength |
|---|---|---|---|
formik | Complex form state | Local, ephemeral | Validation, submission handling |
mobx | Reactive client state | Mutable | Automatic reactivity |
mobx-state-tree | Structured reactive state | Mutable + typed | Snapshots, patches, devtools |
react-query | Server data fetching | Cache-centric | Background sync, mutations |
recoil | Fine-grained React state | Atom/selector | Concurrent mode ready |
redux | Predictable global state | Immutable | Devtools, middleware ecosystem |
xstate | Complex UI workflows | Statecharts | Prevents invalid states |
zustand | Simple global state | Hook-based | Minimal boilerplate |
useState, useReducer) for local component state.react-query as soon as you fetch data from an API.zustand or recoil when you need simple global state.redux only if you need its devtools or middleware (e.g., logging, undo).mobx if your team prefers mutable code and automatic reactivity.xstate for anything with multi-step logic or guarded transitions.formik for forms too complex for basic hooks.The right tool depends on your app’s shape — not trends. Keep server and client state separate, and avoid over-engineering early on.
Choose recoil if you’re building a React app and want a state management system deeply integrated with React’s concurrent rendering features. Its atom-and-selector model allows fine-grained reactivity and derived state with minimal re-renders. It’s well-suited for apps with complex interdependent state that benefits from memoization and async selectors. However, its ecosystem is less mature than Redux’s, and migration from other systems may require significant refactoring.
Choose mobx-state-tree when you want the reactivity of MobX combined with strong runtime type safety, structured state trees, and built-in support for snapshots, patches, and time-travel debugging. It enforces a more disciplined architecture than plain MobX and is excellent for medium-to-large applications requiring predictable state evolution and serialization. It adds complexity, so avoid for small apps where simpler solutions suffice.
Choose formik when you need a battle-tested, dedicated solution for managing complex forms with validation, submission handling, and field-level state. It integrates well with any state management system and avoids the boilerplate of manually wiring up onChange handlers. However, for simple forms or when using modern React patterns like hooks extensively, consider whether a lighter approach (e.g., react-hook-form) might suffice.
Choose mobx when you prefer a mutable, reactive programming model that feels natural and imperative. It’s ideal for teams comfortable with class-based or observable objects and who want automatic reactivity without manual selectors or reducers. Use it when you need fine-grained updates and don’t want to structure your state around immutability. Avoid if your team strongly prefers functional, immutable paradigms.
Choose react-query whenever your app fetches data from servers. It handles caching, background refetching, stale-while-revalidate strategies, pagination, mutations, and error retrying out of the box. It eliminates the need to store server data in global client state and keeps your UI synchronized with the backend automatically. Don’t use it for purely client-side state — pair it with another library like Zustand or Redux for that.
Choose redux when you need a predictable, centralized store with strict unidirectional data flow, extensive devtooling (like time-travel debugging), and a vast middleware ecosystem (e.g., Redux Toolkit, RTK Query). It’s ideal for large teams that benefit from enforced structure and traceability. Avoid it for small apps or when server-state dominates — use react-query for data fetching and consider lighter alternatives like Zustand for client state.
Choose xstate when your UI logic involves complex workflows, multi-step processes, or guarded transitions that are hard to model with boolean flags or enums. Its visualizable statecharts prevent invalid states and make behavior explicit. It pairs well with any React state solution but shines in domains like wizards, modals, drag-and-drop, or device control. Don’t use it for simple CRUD apps where basic state suffices.
Choose zustand when you want a lightweight, hook-based global state solution with minimal boilerplate and no context providers. It uses a mutable store internally but exposes an immutable-like API via hooks, offering great performance and simplicity. It’s perfect for most medium-sized apps needing shared state without Redux’s ceremony. Avoid if you require advanced devtools or strict immutability guarantees.
Recoil is an experimental state management framework for React.
Website: https://recoiljs.org
Documentation: https://recoiljs.org/docs/introduction/core-concepts
API Reference: https://recoiljs.org/docs/api-reference/core/RecoilRoot
Tutorials: https://recoiljs.org/resources
The Recoil package lives in npm. Please see the installation guide
To install the latest stable version, run the following command:
npm install recoil
Or if you're using yarn:
yarn add recoil
Or if you're using bower:
bower install --save recoil
Development of Recoil happens in the open on GitHub, and we are grateful to the community for contributing bugfixes and improvements. Read below to learn how you can take part in improving Recoil.
Recoil is MIT licensed.