Skip to Content

You might not need a global state management library, most of the time

Global state management libraries like Redux , Zustand  or Jotai  are powerful tools, but they come with trade-offs. They store state outside of React and use useSyncExternalStore (Jotai basically re-implements useSyncExternalStore with useReducer with potential slight tearing) to sync it back in.

Before reaching for a global state library, ask yourself: does this state actually need to live outside of React? Most of the time, the answer is likely no.

But I want to access some state across components, pages, or even entire applications!

Most of the time, you just need to lift your useState up in your React tree. foxact/context-state makes it easy to create providers and hooks with minimal boilerplate and avoid prop drilling:

src/context/sidebar-active.tsx
'use client'; import { createContextState } from 'foxact/context-state'; export const [SidebarActiveProvider, useSidebarActive, useSetSidebarActive] = createContextState(false);

Wrap your layout (or any shared ancestor) with the provider:

src/layout/main-layout.tsx
import { memo } from 'react'; import { SidebarActiveProvider } from '../context/sidebar-active'; export default memo(function MainLayout({ children }: React.PropsWithChildren) { return ( <SidebarActiveProvider> {children} </SidebarActiveProvider> ); });

Then read and update the state anywhere inside the subtree by importing the hooks:

src/components/sidebar.tsx
import { memo } from 'react'; import { useSidebarActive, useSetSidebarActive } from '../context/sidebar-active'; export default memo(function Sidebar() { const sidebarActive = useSidebarActive(); const setSidebarActive = useSetSidebarActive(); return ( <div className={`sidebar ${sidebarActive ? 'active' : ''}`}> <button onClick={() => setSidebarActive(false)}>Close Sidebar</button> </div> ); });
src/components/navbar.tsx
import { memo } from 'react'; import { useSetSidebarActive } from '../context/sidebar-active'; export default memo(function Navbar() { // Only calls the setter — this component never re-renders when sidebarActive changes const setSidebarActive = useSetSidebarActive(); return ( <div className="navbar"> <button onClick={() => setSidebarActive(active => !active)}>Menu Button</button> </div> ); });

See the full foxact/context-state docs for more details, like how to provide an initial state within the React tree instead of at the module level. You may also be interested in foxact/context-reducer where you can do the same thing but with reducer and dispatch.

Tips: If you have multiple context states, you can use foxact/compose-context-provider to avoid nesting JSX hell:

src/layout/main-layout.tsx
import { memo } from 'react'; import { ComposeContextProvider } from 'foxact/compose-context-provider'; export default memo(function MainLayout({ children }: React.PropsWithChildren) { return ( <ComposeContextProvider providers={[ <ProivderA />, <ProviderB initialState={42} />, <ProviderC initialState={() => 13} />, <SWRConfig value={{ keepPreviousData: true }} /> ]} > {children} </ComposeContextProvider> ); });

But I want to persist states via localStorage!

If you need the same localStorage-backed value across multiple components and want them all to stay in sync, use foxact/create-local-storage-state. It is kinda like createContextState but reads from and writes to localStorage:

src/state/user-config.ts
import { createLocalStorageState } from 'foxact/create-local-storage-state'; export const [useUserConfigState, useUserConfigValue, useSetUserConfig] = createLocalStorageState( 'app-local-setting', // localStorage key {}, // Initial / SSR server value { // Optional serialize and deserialize options goes here. // Default to JSON.stringify and JSON.parse, you can also // specify `raw: true` here to store and read value as-is } );

Then use the hooks anywhere in your app:

src/components/a.tsx
import { memo } from 'react'; import { useUserConfigValue, useUserConfigState } from '../state/user-config'; export default memo(function ComponentA() { const config = useUserConfigValue(); }); function Toggle() { const [config, setConfig] = useUserConfigState(); return ( <Switch value={config.someFlag} onChange={useCallback((newValue) => { setConfig(prev => ({ ...prev, someFlag: newValue })); }, [setConfig])} // `setConfig` is memoized, so it's safe to add it to dependencies array /> ) }
src/components/config-editor.tsx
import { memo } from 'react'; import { useSetUserConfig } from '../state/user-config'; export default memo(function ConfigEditor() { const setUserConfig = useSetUserConfig(); const handleSaveButtonClick = useCallback(() => { setUserConfig(newConfig); }, [setUserConfig]); // `setUserConfig` is memoized, so it's safe to add it to dependencies array });

All components that call useUserConfigValue(), useSetUserConfig(), or useUserConfigState() will use the localStorage as the single source of truth and stay in sync automatically — including across browser tabs.

Read the full foxact/create-local-storage-state docs for more usage details, including how you can use <Suspense /> or server default value to support server-side rendering.

If you only need to read and write a localStorage value in one component, foxact/use-local-storage is the perfect tool for the job. It is a drop-in replacement for useState that syncs with localStorage:

src/components/shape-picker.tsx
import { useLocalStorage } from 'foxact/use-local-storage'; function ShapePicker() { const [shape, setShape] = useLocalStorage<'circle' | 'square' | 'triangle'>( 'shape', // localStorage key 'circle', // initial / SSR value /** * serialize and deserialize options goes here, default to JSON.stringify and JSON.parse. * * you can also specify `raw: true` here to store and read value as-is, just like what * we are doing here. */ { raw: true } ); return ( <div> <button onClick={() => setShape('circle')}>Circle</button> <button onClick={() => setShape('square')}>Square</button> <button onClick={() => setShape('triangle')}>Triangle</button> <p>Current shape: {shape}</p> </div> ); }

And your shape state is now persisted in localStorage and shared across all tabs automatically!

Read the full foxact/use-local-storage docs for more usage details, including how you can use <Suspense /> or server default value to support server-side rendering.

But I want to fetch some data and share it across components and pages!

Async resources like remote data fetching is actually a good use case for global state management libraries. Under the hood, libraries like SWR  are actually also global state management libraries, but unlike those general-purpose global state management libraries (like Redux, Zustand, Jotai we mentioned earlier), SWR  is designed and optimized specifically for remote data fetching with built-in caching, deduplication, and cache revalidation. It is a great choice for most data fetching needs in React apps:

src/hooks/user.ts
'use client'; import useSWR from 'swr'; export function useUser(userId: string) { return useSWR( // you can pass any value as the SWR key, most examples use string, but // here we use an object to demonstrate that SWR can also accept non-string keys { url: `/api/user/${userId}`, method: 'GET' }, ({ url, method }) => fetch(url, { method }).then(res => res.json()) ); }

Now you can call useUser anywhere in your app, and they will share the same underlying data with only one HTTP request toward your backend (that’s the deduplication!):

src/components/profile.tsx
'use client'; import { memo } from 'react'; import { useUser } from '../hooks/user'; export default memo(function Profile({ userId }: { userId: string }) { const { data: user, error, isLoading } = useUser(userId); if (error) return <div>Failed to load user</div>; if (isLoading) return <div>Loading...</div>; return ( <div> <h1>{user.name}</h1> <p>{user.email}</p> </div> ); });
src/components/navbar.tsx
'use client'; import { memo } from 'react'; import { useUser } from '../hooks/user'; export default memo(function Navbar({ userId }: { userId: string }) { const { data: user } = useUser(userId); return ( <nav> <img src={user?.avatarUrl || '/avatar-loading.svg'} alt={user ? `${user.name}'s Avatar` : 'User Avatar is loading'} /> </nav> ); });

And you can use useSWR for anything asynchronous, not just remote data fetching:

src/hooks/use-barcode-detector.ts
'use client'; import useSWRImmutable from 'swr/immutable'; const useBarcodeDetectorInstance = () => useSWRImmutable( 'get-barcode-detector', async () => { let isUseBrowserBuiltInBarcodeDetector = 'BarcodeDetector' in window; // feature detection if (isUseBrowserBuiltInBarcodeDetector) { try { window.BarcodeDetector.getSupportedFormats(); } catch { isUseBrowserBuiltInBarcodeDetector = false; } } try { const BarcodeDetectorImpl = isUseBrowserBuiltInBarcodeDetector ? window.BarcodeDetector : (await import('@preflower/barcode-detector-polyfill')).BarcodeDetectorPolyfill; const supportedFormats = await BarcodeDetectorImpl.getSupportedFormats(); if (supportedFormats.includes('qr_code')) { return new BarcodeDetectorImpl({ formats: ['qr_code'] }); } return null; } catch { return null; } } );

If you are using SWR  together with Hey API  (generate TypeScript Client SDK from OpenAPI spec), you may be interested in tayori , an opinionated React client-side data fetching stack built on top of SWR and Hey API.

But I need to produce derived state from other states and share it across components and pages!

If your derived state is synchronous and cheap to compute, you can do it on the fly in your components. You can also use useMemo to memoize the derived state to prevent unnecessary re-computations and re-renders:

src/context/sidebar-active.tsx
'use client'; import { createContextState } from 'foxact/context-state'; export const [SidebarActiveProvider, useSidebarActive, useSetSidebarActive] = createContextState(false); export const [GlobalDrawerProvider, useGlobalDrawer, useSetGlobalDrawer] = createContextState(false); // This is an over-simplified example to demonstrate the point export function useIsPanelActive() { const sidebarActive = useSidebarActive(); const globalDrawer = useGlobalDrawer(); return useMemo(() => sidebarActive && globalDrawer, [sidebarActive, globalDrawer]); }

If your computation is asynchronous, or it is coming from an async source, you may use useSWR and compute it in your fetcher function. It will then be cached and only computed during first fetch and later cache invalidations:

src/hooks/use-server-config.ts
'use client'; import useSWR from 'swr'; const useFinalizedServerConfig = () => useSWR( { url: '/api/server-config', method: 'GET' }, async ({ url, method }) => { const r = await fetch(url, { method }); const rawConfig = await r.json(); // run computation here return await produceFinalConfig(rawConfig) } ); const { data: finalizedConfig, error, isLoading } = useFinalizedServerConfig();
src/hooks/use-indexed-db-data.ts
import { useSingleton } from 'foxact/use-singleton'; import useSWRImmutable from 'swr/immutable'; const useLocalConfigFromIndexedDB = (key) => { // `useSingleton` is a hook provided by `foxact` that only runs the initializer // function once and holds the same return value across re-renders const idbkvInstance = useSingleton(() => new IDBKeyVal('my-db', 'my-store')); return useSWRImmutable( { key }, async ({ key }) => { const localData = await idbkvInstance.current.get(key); // run computation here return makeTransformation(await deserialize(localData)); } ); }

But I need to render a stream/flow of changing data!

Streaming and subscription logic like this is another good use case for a global state management library — but aforementioned SWR  (which, as I already mentioned earlier, is a global state management library) is still your best friend to handle this kind of state.

WebSocket and alike

'use client'; import useSWRSubscription from 'swr/subscription'; useSWRSubscription( 'my-websocket-channel', (channel, { next }) => { const ws = new WebSocket(`wss://example.com/ws/${channel}`); ws.onmessage = (event) => { next(null, JSON.parse(event.data)); }; ws.onerror = (err) => { next(err); }; return () => ws.close(); } );

Server-Sent Events (SSE)

'use client'; import { fetchEventSource } from '@microsoft/fetch-event-source'; import useSWRSubscription from 'swr/subscription'; useSWRSubscription( channel, (channel, { next }) => { const abortController = new AbortController(); fetchEventSource(`/api/feed/${channel}`, { signal: abortController.signal, onmessage(ev) { next(null, JSON.parse(ev.data)); }, onerror(err) { next(err); }, }); return () => abortController.abort(); } );

Generators and Iterators

'use client'; import useSWRSubscription from 'swr/subscription'; useSWRSubscription( channel, (channel, { next }) => { const abortController = new AbortController(); (async () => { try { for await (const data of await getAsyncIterableSource(channel)) { if (abortController.signal.aborted) { return; } next(null, data); } } catch (err) { next(err); } })(); return () => abortController.abort(); } );

Of course, there are many other cases where you don’t need a global state management library, and there are also cases where you do need a global state management library for your complex logic. The bottom line is: reach for a global state management library only when you have a clear need for it, and be mindful of the trade-offs it brings!