Skip to content
    All posts
    Frontend

    State Management in React Without a State Management Library

    Most apps reaching for Redux have three kinds of state and are treating all of them the same way. Separate them and the library becomes optional.

    3 min readby

    I've built apps with Redux, with MobX, with Zustand, and with none of them. The pattern I keep returning to isn't a library choice. It's recognising that "state" is three unrelated problems wearing a trench coat.

    The three kinds

    • Server state — data that lives in a database and is cached in your app. Users, orders, listings. It can go stale, it needs loading and error states, and two tabs can disagree about it.
    • URL state — the current route, filters, pagination, the open tab. State the user expects to survive a refresh and be shareable via a link.
    • UI state — is the dropdown open, what's typed in this input, which step of the wizard. Ephemeral, local, nobody's business but the component's.

    Classic Redux apps put all three in one store, and it's the first category that causes the damage. You end up hand-writing loading flags, error flags, cache invalidation, and refetch logic for every endpoint — reimplementing a caching library, badly, one action creator at a time.

    Server state: a query library, not a store

    tsx
    function EmployeeList({ page }: { page: number }) {
      const { data, isPending, error } = useQuery({
        queryKey: ['employees', page],
        queryFn: () => api.employees.list(page),
        staleTime: 30_000,
      });
    
      if (isPending) return <ListSkeleton />;
      if (error) return <ErrorState error={error} onRetry={refetch} />;
      return <Table rows={data.items} />;
    }

    Caching, deduplication, background refetch, retry, stale-while-revalidate, and request cancellation — all of it, for free. The equivalent in a hand-rolled store is somewhere between 200 and 2,000 lines depending on how thorough you are, and it will have bugs the library fixed three years ago.

    The mental shift is giving up on "one source of truth in my store." The source of truth for server data is the server. Your app holds a cache of it, and a cache has its own semantics — freshness, invalidation, revalidation — that a plain object store doesn't model.

    URL state: the router already has it

    tsx
    const [params, setParams] = useSearchParams();
    const status = params.get('status') ?? 'all';
    const page = Number(params.get('page') ?? 1);
    
    const setStatus = (next: string) =>
      setParams((prev) => {
        prev.set('status', next);
        prev.set('page', '1');   // filter change resets pagination
        return prev;
      });

    The payoff isn't code size, it's behaviour. Refresh preserves the view. The back button does what the user means. Someone can paste a link into Slack and their colleague sees the same filtered table. Every one of those is free here and is custom work if the filter lives in a store.

    My default rule: if a user would be annoyed to lose it on refresh, it belongs in the URL.

    UI state: useState, and stop there

    Dropdown open, hover, uncommitted input, which accordion panel is expanded. useState in the component that owns it. Lift it only when a sibling genuinely needs it, and lift it exactly one level — not to the root.

    What's left

    After splitting those three, the residue is small: theme, current user, feature flags, maybe a toast queue. Things that are genuinely global and change rarely. Context handles all of it.

    tsx
    const ThemeContext = createContext<ThemeValue | null>(null);
    
    export function ThemeProvider({ children }: { children: React.ReactNode }) {
      const [theme, setTheme] = useState<Theme>('light');
      const value = useMemo(() => ({ theme, setTheme }), [theme]);
      return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
    }
    
    export function useTheme() {
      const ctx = useContext(ThemeContext);
      if (!ctx) throw new Error('useTheme must be used inside ThemeProvider');
      return ctx;
    }
    

    That error message matters more than it looks. Without it, a missing provider surfaces as Cannot read properties of null somewhere unrelated, and you lose twenty minutes.

    When a store is genuinely the right call

    I'm not against state libraries. I reach for Zustand when there's real client-side domain state that isn't server data and isn't URL data — a collaborative editor's document model, a design tool's canvas, a multi-step flow with interdependent computed steps. That's a real category, and hooks alone handle it awkwardly.

    Choose the library after you've named the problem. Choosing it first means every problem gets shaped to fit the tool.
    ReactStateArchitectureTanStack Query

    Keep reading

    PAPulapa Arun Kumar

    Full-stack developer building performant, clean, and user-friendly web & mobile applications. Also written as Arun Kumar Pulapa — same person, surname first.

    Built with

    ReactTypeScriptTailwind CSSVite

    © 2026 Pulapa Arun Kumar. All rights reserved.