React 19 introduces the use() hook, a powerful primitive that enables components to read resources like Promises and Context more ergonomically. For teams currently using Zustand, this new capability presents an opportunity to simplify state management patterns and reduce external dependencies. This migration path requires careful consideration of trade-offs between Zustand's mature ecosystem and React's native capabilities.
Understanding React 19's use() Hook
The use() hook differs from traditional hooks by allowing conditional calls and supporting Promise resolution directly within components. Unlike useState or useEffect, use() can appear inside loops and conditional statements, making it more flexible for dynamic data scenarios.
1import { use } from 'react';
2
3function UserProfile({ userPromise }) {
4 // use() unwraps the Promise automatically
5 const user = use(userPromise);
6
7 return (
8 <div>
9 <h2>{user.name}</h2>
10 <p>{user.email}</p>
11 </div>
12 );
13}The hook integrates with Suspense boundaries, automatically triggering loading states when Promises are pending. This creates a declarative approach to async state management that eliminates much of the boilerplate typically associated with loading and error states.
Comparing State Management Approaches
Zustand provides a minimalist store with selectors, middleware support, and automatic re-render optimization. A typical Zustand store handles global state with straightforward subscription patterns:
1import create from 'zustand';
2
3interface UserStore {
4 user: User | null;
5 isLoading: boolean;
6 error: Error | null;
7 fetchUser: (id: string) => Promise<void>;
8}
9
10const useUserStore = create<UserStore>((set) => ({
11 user: null,
12 isLoading: false,
13 error: null,
14 fetchUser: async (id) => {
15 set({ isLoading: true, error: null });
16 try {
17 const response = await fetch(`/api/users/${id}`);
18 const user = await response.json();
19 set({ user, isLoading: false });
20 } catch (error) {
21 set({ error: error as Error, isLoading: false });
22 }
23 }
24}));React 19's approach shifts this pattern toward Context and Promises. The equivalent implementation leverages Context for state distribution and use() for async resolution:
1import { createContext, use, cache } from 'react';
2
3// Cache prevents duplicate fetches
4const fetchUser = cache(async (id: string) => {
5 const response = await fetch(`/api/users/${id}`);
6 if (!response.ok) throw new Error('Failed to fetch user');
7 return response.json();
8});
9
10const UserContext = createContext<(id: string) => Promise<User>>(fetchUser);
11
12function UserProfile({ userId }: { userId: string }) {
13 const getUser = use(UserContext);
14 const user = use(getUser(userId));
15
16 return (
17 <div>
18 <h2>{user.name}</h2>
19 <p>{user.email}</p>
20 </div>
21 );
22}
23
24// Wrap with Suspense and ErrorBoundary
25function App() {
26 return (
27 <ErrorBoundary>
28 <Suspense fallback={<LoadingSpinner />}>
29 <UserProfile userId="123" />
30 </Suspense>
31 </ErrorBoundary>
32 );
33}Migration Strategies for Complex Stores
Applications with multiple interconnected stores require a phased migration approach. The key challenge involves maintaining cross-store dependencies while transitioning to Context-based patterns.
Handling Derived State
Zustand excels at computed values through selectors. When a store calculates derived state from multiple sources, the migration needs to preserve these dependencies:
1// Zustand approach with derived state
2const useCartStore = create((set, get) => ({
3 items: [],
4 discount: 0,
5
6 // Derived value
7 get total() {
8 const { items, discount } = get();
9 const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
10 return subtotal * (1 - discount);
11 },
12
13 addItem: (item) => set((state) => ({
14 items: [...state.items, item]
15 })),
16
17 setDiscount: (discount) => set({ discount })
18}));The React 19 equivalent separates data fetching from computation, using useMemo for derived values:
1import { createContext, use, useMemo, useState } from 'react';
2
3interface CartItem {
4 id: string;
5 price: number;
6 quantity: number;
7}
8
9interface CartContextValue {
10 items: CartItem[];
11 discount: number;
12 addItem: (item: CartItem) => void;
13 setDiscount: (discount: number) => void;
14}
15
16const CartContext = createContext<CartContextValue | null>(null);
17
18function CartProvider({ children }: { children: React.ReactNode }) {
19 const [items, setItems] = useState<CartItem[]>([]);
20 const [discount, setDiscount] = useState(0);
21
22 const addItem = (item: CartItem) => {
23 setItems(prev => [...prev, item]);
24 };
25
26 const value = useMemo(() => ({
27 items,
28 discount,
29 addItem,
30 setDiscount
31 }), [items, discount]);
32
33 return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
34}
35
36function CartTotal() {
37 const cart = use(CartContext);
38
39 const total = useMemo(() => {
40 const subtotal = cart.items.reduce(
41 (sum, item) => sum + item.price * item.quantity,
42 0
43 );
44 return subtotal * (1 - cart.discount);
45 }, [cart.items, cart.discount]);
46
47 return <div>Total: ${total.toFixed(2)}</div>;
48}Performance Considerations
Advertisement
Zustand's subscription model provides fine-grained control over re-renders through selector functions. Components only re-render when selected state slices change:
1// Only re-renders when user.name changes
2const userName = useUserStore(state => state.user?.name);Context-based approaches in React 19 require more deliberate optimization. Without proper memoization, every Context consumer re-renders when any value changes. The solution involves splitting Contexts by update frequency:
1// Separate contexts for different update patterns
2const CartItemsContext = createContext<CartItem[]>([]);
3const CartActionsContext = createContext<CartActions | null>(null);
4
5function CartProvider({ children }: { children: React.ReactNode }) {
6 const [items, setItems] = useState<CartItem[]>([]);
7
8 // Actions don't change, preventing unnecessary re-renders
9 const actions = useMemo(() => ({
10 addItem: (item: CartItem) => setItems(prev => [...prev, item]),
11 removeItem: (id: string) => setItems(prev => prev.filter(i => i.id !== id))
12 }), []);
13
14 return (
15 <CartActionsContext.Provider value={actions}>
16 <CartItemsContext.Provider value={items}>
17 {children}
18 </CartItemsContext.Provider>
19 </CartActionsContext.Provider>
20 );
21}This pattern mirrors Zustand's efficiency by ensuring components only subscribe to the specific data they need.
Middleware and DevTools Trade-offs
Zustand's middleware ecosystem provides persistence, immer integration, and DevTools support out of the box. Teams migrating away from these features need alternative solutions.
For persistence, React 19 applications can implement custom hooks that sync Context state with localStorage:
1function useSyncedState<T>(key: string, initialValue: T) {
2 const [state, setState] = useState<T>(() => {
3 const stored = localStorage.getItem(key);
4 return stored ? JSON.parse(stored) : initialValue;
5 });
6
7 useEffect(() => {
8 localStorage.setItem(key, JSON.stringify(state));
9 }, [key, state]);
10
11 return [state, setState] as const;
12}DevTools integration requires more effort. The React DevTools can inspect Context values, but lack Zustand's time-travel debugging. For complex applications, maintaining a thin Zustand layer for debugging while using React 19 patterns for new features provides a pragmatic transition path.
When to Stick with Zustand
Certain scenarios favor keeping Zustand despite React 19's new capabilities. Applications with complex state machines, heavy cross-component coordination, or extensive middleware dependencies may find the migration cost outweighs the benefits.
Zustand's external store subscription model enables state access outside React components, useful for WebSocket handlers, service workers, or utility functions. React Context requires component boundaries, making non-component state access more cumbersome.
Performance-critical applications with hundreds of components subscribing to different state slices benefit from Zustand's selector optimization. While Context splitting achieves similar results, it requires more architectural planning and boilerplate.
Practical Migration Path
Start by identifying stores with minimal dependencies and straightforward async operations. These candidates migrate cleanly to use() and Context patterns. Convert one store at a time, maintaining parallel implementations during the transition to ensure stability.
For stores with complex interdependencies, consider a hybrid approach: keep Zustand for core state management while using use() for new async features. This incremental strategy reduces risk and allows teams to evaluate performance characteristics before committing fully.
The use() hook represents a significant step toward standardizing async state patterns in React. Teams should evaluate their specific requirements—considering bundle size, performance needs, and development velocity—before deciding whether migration delivers meaningful value over Zustand's proven approach.





