Why Zustand Is the Best Choice for Complex Frontend State Management
This article examines the pitfalls of traditional React state management in complex applications, explains why Zustand offers superior state sharing, mutation, derivation, and performance optimization, and provides detailed code examples and best practices for integrating Zustand into large-scale frontend projects.
🙋🏻♀️ Editor's note: The author, a designer at Ant Group (known as "Konggu" in the community), first introduces the state‑management pitfalls of complex applications, argues that Zustand is the best current choice for complex state management, and explains the reasons from six aspects: state sharing, state mutation, state derivation, performance optimization, data fractal, and multi‑environment integration. This is part one; part two will cover progressive state‑management practice with Zustand.
As a designer who dabbles in frontend development, I used Dva until the end of 2020 because it offered a low learning curve and worked well for simple state management.
Since the rise of React hooks and TypeScript, many articles promoted "you don't need Redux", "useState + Context is enough", etc. Dva stopped being maintained, exposing TypeScript issues.
After using hooks in small projects without problems, I started encountering pitfalls in complex applications.
State Management Pitfalls in Complex Applications
ProEditor is the editor component of the internal component library TechUI Studio.
ProEditor is a typical example: as an editor, it requires rich interaction and thus a large amount of state management.
ProEditor’s state‑management needs include:
❶ Split editor container state and component (Table) state, but allow them to interact.
The container state holds global configuration such as canvas, code page switching, and activation of canvas interaction, while component state stores each component’s configuration.
Separating them allows reuse of the container state across different components (e.g., ProForm) while each component maintains its own store.
Initially I used Provider + Context for global state management:
export const useStudioStore = (props?: ProEditorProps) => {
// ...
const tableStore = useTableStore(props?.value);
const [tabKey, switchTab] = useState(TabKey.canvas);
const [activeConfigTab, switchConfigTab] = useState<TableConfigGroup>(TableConfigGroup.Table);
// ...
}
export const StudioStore = createContextStore(useStudioStore, {});
// consumption
const NavBar: FC<NavBarProps> = ({ logo }) => {
const { tabKey } = useContext(StudioStore);
return ...
}This approach caused every click (e.g., on canvas or component config) to re‑render the entire panel, as shown in the re‑render analysis graphs.
❷ Complex data processing
ProEditor performs many data transformations, e.g., the columns field has 14 different update operations, such as updateColumnByOneAPI which fine‑tunes column information based on a one‑API field.
To keep actions immutable, I used a custom userReducer to manage change methods.
Custom hooks must be written in a way that avoids repeated renders, otherwise debugging becomes painful.
const useDataColumns = () => {
const createOrUpdateColumnsByMockData = useCallback(() => {
// ...
}, [a, b]);
const createColumnsByOneAPI = useCallback(() => {
// ...
}, [c, d]);
const updateColumnsByOneAPI = useCallback(() => {
// ...
}, [a, b, c, d]);
// ...
}However, useReducer cannot handle async functions, internal reducer calls, or state linkage, so it is not optimal.
❸ Externally consumable component
To support both controlled and uncontrolled modes, ProEditor exposes config and interaction as controlled values.
Example of external consumption:
export default () => {
const [status, setStatus] = useState();
const { config, getState } = useState();
return (
<ProEditor
config={config}
onConfigChange={(config) => { setConfig(config); }}
interaction={status}
onInteractionChange={(s) => { setStatus(s); }}
/>
);
}This pattern initially caused a dead loop because useEffect triggered onChange on every render.
const useTableStore = (state) => {
const { defaultConfig, config: outsourceValue, mode } = props;
const { columns, isEmptyColumns, dispatchColumns } = useColumnStore(defaultConfig?.columns, mode);
// controlled mode sync
useEffect(() => {
if (!outsourceValue) return;
if (isEqual(dataStore, outsourceValue)) return;
if (outsourceValue.columns) {
dispatchColumns({ type: 'setAll', columns: outsourceValue.columns });
}
}, [dataStore, outsourceValue]);
const dataStore = useMemo(() => {
const v = { ...store, data, columns } as ProTableConfigStore;
if (props.onChange && !isEqual(v, outsourceValue)) {
props.onChange?.({ config: v, props: tableAsset.generateProps(v), isEmptyColumns });
}
return v;
}, [data, store, columns, outsourceValue]);
}The root cause was the timing of onChange inside useEffect, which often leads to infinite loops.
❹ Future needs: undo/redo, shortcuts, etc.
Modern editors require keyboard shortcuts, history, and collaboration, which should be implemented with low cost and high maintainability.
Bottom line: Complex applications cannot rely on raw hooks for state management.
Why Zustand?
State management is essentially a product for developers. Zustand satisfies almost all developer needs with a superior experience.
❶ State Sharing
Context provides basic state sharing but requires a Provider. Zustand shares state without a Provider by default.
// Context sharing
// store.ts
export const StoreContext = createStoreContext(() => { ... });
// index.tsx
root.render(
<StoreContext.Provider value={appState}>
<App />
</StoreContext.Provider>
); // Zustand sharing
import create from 'zustand';
export const useStore = create(set => ({
count: 1,
inc: () => set(state => ({ count: state.count + 1 })),
}));
function Control() {
return <button onClick={() => useStore.setState(s => ({ ...s, count: s.count - 5 }))}>-5</button>;
}
function AnotherControl() {
const inc = useStore(state => state.inc);
return <button onClick={inc}>+1</button>;
}
function Counter() {
const { count } = useStore();
return <h1>{count}</h1>;
}Zustand also supports multi‑instance stores via a Provider when needed.
❷ State Mutation
Hooks’ setState is atomic and cannot handle complex logic; useReducer mimics Redux but lacks async support. Zustand lets you write functions directly, handling sync and async without extra boilerplate.
// Redux‑Toolkit example (simplified)
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
const fetchUserById = createAsyncThunk('users/fetchByIdStatus', async (userId) => {
const response = await userAPI.fetchById(userId);
return response.data;
});
const usersSlice = createSlice({
name: 'users',
initialState: { entities: [], loading: 'idle' },
reducers: {},
extraReducers: builder => {
builder.addCase(fetchUserById.fulfilled, (state, action) => {
state.entities.push(action.payload);
});
},
});
dispatch(fetchUserById(123)); // Zustand mutation example
import create from 'zustand';
export const useStore = create((set, get) => ({
...initialState,
createNewDesignSystem: async () => {
const { params, toggleLoading } = get();
toggleLoading();
const res = await dispatch('/hitu/remote/create-new-ds', params);
toggleLoading();
if (!res) return;
set({ created: true, designId: res.id });
},
toggleLoading: () => set(state => ({ loading: !state.loading })),
}));Zustand keeps all functions with stable references, preventing unnecessary re‑renders.
❸ State Derivation
Derived state can be simple (e.g., URL from a name) or complex (e.g., color space from RGB/HSL). In hooks you use useMemo; Zustand provides selector functions.
// Hook derivation
const [name, setName] = useState('');
const url = useMemo(() => URL_HITU_DS_BASE(name || ''), [name]); // Zustand selector
const url = useStore(state => URL_HITU_DS_BASE(state.name || ''));
export const dsUrlSelector = s => URL_HITU_DS_BASE(s.name || '');
const url = useStore(dsUrlSelector);Selectors are pure functions, easy to test and can be placed in separate files.
❹ Performance Optimization
Using selectors with shallow comparison ( zustand/shallow) allows components to subscribe only to the parts they need, drastically reducing re‑renders.
import shallow from 'zustand/shallow';
import { useStore, ProTableStore } from './store';
const selector = (s: ProTableStore) => ({
tabKey: s.tabKey,
internalSetState: s.internalSetState,
});
const TableConfig: FC = () => {
const { tabKey, internalSetState } = useStore(selector, shallow);
// ...
};❺ Data Fractal & State Composition
Using a fractal (slice) architecture, stores can be composed and extended with middleware such as persist or devtools.
// Slice pattern (official example)
import create, { StateCreator } from 'zustand';
interface BearSlice {
bears: number;
addBear: () => void;
eatFish: () => void;
}
const createBearSlice: StateCreator<BearSlice & FishSlice, [], [], BearSlice> = set => ({
bears: 0,
addBear: () => set(state => ({ bears: state.bears + 1 })),
eatFish: () => set(state => ({ fishes: state.fishes - 1 })),
});
interface FishSlice {
fishes: number;
addFish: () => void;
}
const createFishSlice: StateCreator<BearSlice & FishSlice, [], [], FishSlice> = set => ({
fishes: 0,
addFish: () => set(state => ({ fishes: state.fishes + 1 })),
});
export const useBoundStore = create<BearSlice & FishSlice>(
(...a) => ({ ...createBearSlice(...a), ...createFishSlice(...a) })
);Persist middleware saves state to localStorage with minimal code.
import create from 'zustand';
import { persist } from 'zustand/middleware';
interface BearState {
bears: number;
increase: (by: number) => void;
}
export const useBearStore = create<BearState>(
persist(set => ({
bears: 0,
increase: by => set(state => ({ bears: state.bears + by })),
}))
);The devtools middleware connects the store to Redux DevTools, allowing custom action names (even in Chinese) for better debugging.
const vanillaStore = (set, get) => ({
syncOutSource: nextState => {
set({ ...get(), ...nextState }, false, `Controlled update: ${Object.keys(nextState).join(' ')}`);
},
syncOutSourceConfig: ({ config }) => {
set({ ...get(), ...config }, false, 'Controlled update: component config');
},
});
const createStore = create(devtools(vanillaStore, { name: 'ProTableStore' }));❻ Multi‑Environment Integration (React & Outside)
Zustand stores can be accessed outside React, enabling seamless integration with non‑React environments (e.g., canvas, 3D scenes).
// Create store
const useDogStore = create(() => ({ paw: true, snout: true, fur: true }));
// Outside React: read value
const paw = useDogStore.getState().paw;
// Subscribe to changes
const unsub = useDogStore.subscribe(console.log);
// Outside React: update value
useDogStore.setState({ paw: false });
// Inside React
const Component = () => {
const paw = useDogStore(state => state.paw);
return <div>{paw ? 'Paw' : 'No Paw'}</div>;
};Conclusion: Zustand Is the Best Choice for Complex State Management
After months of practice in the ProEditor project, I am convinced that Zustand is the optimal solution for complex state management in most frontend applications.
Future articles will cover progressive state‑management strategies with Zustand, including controlled patterns, RxJS integration, and more.
If you find this interesting, please follow us 💁🏼♀️
👇🏾 Click "Read Original" to comment and interact.
Alipay Experience Technology
Exploring ultimate user experience and best engineering practices
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
