Back to Blog
The State of State Management: Moving Beyond Hooks
Photo from Unsplash

Article content

This is Part 7 of the The 2026 Developer Stack series (11 posts on the tools, workflows, and architectural patterns that define modern frontend engineering).


Introduction

For years, the mantra in React was "keep state close to the UI," which in practice meant a proliferation of client-side state management libraries and an ever-expanding pile of useState/useEffect pairs fetching and caching data that never needed to live in the browser at all. React Server Components (RSC) and Server Actions reverse that default: fetch and mutate on the server, and only reach for client state when something is genuinely interactive. This "Thinner Client" architecture changes what state management even means for most application data.

The Rise of React Server Components (RSC)

RSC allows components to fetch data and render on the server, sending only the resulting HTML and the serialized data an interactive child component actually needs, not a client-side data-fetching waterfall wrapped in loading and error states.

This changes how state gets categorized. Much of the data previously managed in client-side state (Redux, MobX, or a useState/useEffect pair doing its own fetch-and-cache) can now be read directly on the server, at render time, with no client-side equivalent at all. A product listing page that used to dispatch a fetch action, track a loading boolean, and store the result in a global store becomes an async Server Component that awaits the data directly. There's no loading state to manage because the server doesn't render the component until the data is ready, and there's no cache invalidation logic to write because Next.js's fetch caching handles that layer.

Server Actions: The New Way to Mutate

State management isn't just about reading data, it's also about mutating it, and Server Actions have become the default way to handle form submissions and other mutations in applications built on the App Router.

Server Actions are functions that run on the server but can be called directly from client-side components, passed as a form's action prop or invoked from an event handler. This eliminates the need for a manual API route plus the client-side fetch call, response parsing, and error handling that used to surround every mutation. Instead, useActionState (the evolved version of useFormState) handles the UI's response to the mutation declaratively: pending state, validation errors, and the mutation's result all come back through one hook, without a separate isLoading flag threaded through the component by hand.

// app/actions.ts
'use server';
 
export async function updateProfile(prevState: unknown, formData: FormData) {
  const name = formData.get('name') as string;
  if (!name || name.length < 2) {
    return { error: 'Name must be at least 2 characters' };
  }
  await db.user.update({ name });
  return { success: true };
}
// app/profile/ProfileForm.tsx
'use client';
import { useActionState } from 'react';
import { updateProfile } from '../actions';
 
export function ProfileForm() {
  const [state, formAction, isPending] = useActionState(updateProfile, null);
  return (
    <form action={formAction}>
      <input name="name" />
      {state?.error && <p role="alert">{state.error}</p>}
      <button disabled={isPending}>{isPending ? 'Saving…' : 'Save'}</button>
    </form>
  );
}

The validation logic lives in one place (the server action), runs before the database write regardless of what the client sent, and the client component only has to render whatever state comes back. No duplicate validation logic on the client, no risk of the two falling out of sync.

Moving Toward a Thinner Client

The result of this shift is a significantly thinner client. By moving data-fetching and mutation logic to the server, we're able to:

  1. Reduce Bundle Size: We're no longer shipping massive state management and data-fetching libraries to the client.
  2. Improve Performance: The client has less work to do, leading to faster initial page loads and a more responsive user experience.
  3. Simplify Codebase: We're replacing complex, asynchronous client-side state management with simpler, more direct server-side patterns.

While client-side state still has its place for purely interactive UI elements (modals, dropdowns, in-progress form input before submission, animation state), the source of truth for application data has moved back to the server.

Where Client State Still Belongs

The mistake teams make after adopting this pattern is trying to eliminate client state entirely, which produces its own class of bug: a Server Action that revalidates the whole page on every keystroke of a search box, or a modal's open/closed state routed through a server round-trip that adds a visible delay to what should be instant. The dividing line isn't "server good, client bad," it's whether the state represents something the server needs to know about. A shopping cart's contents need to survive a page refresh and sync across tabs, so it belongs on the server (or at minimum, a client store synced to the server). Whether a dropdown is currently open does not need to survive anything, it's disposable UI state that exists purely for the current interaction, and routing it through a server round-trip adds latency for zero benefit. useState is still the right tool for state that has no meaning outside the component that owns it.

TanStack Query and similar libraries haven't disappeared either, they've narrowed to the case RSC doesn't solve: client-side data that needs to update reactively without a full page interaction, like a live-updating notification count or a search-as-you-type results list, where you specifically want client-side caching, background refetching, and optimistic updates. The skill isn't picking one pattern and applying it everywhere, it's correctly sorting each piece of state into "the server should own this" or "this is genuinely client-only" and reaching for the matching tool.

Conclusion

The practical first step is identifying which useEffect fetches in your codebase could be moved to Server Components today. In Next.js App Router, any async page or layout component can fetch directly: no client bundle, no loading state boilerplate, no cache management. Start there. Once you've felt the reduction in complexity, the "Thinner Client" philosophy starts making its own case.

Next in the series: AI-Native Design Systems → A thinner client is faster and simpler. It's also one AI agents can consume more reliably. Next: what happens when your design system's primary consumer is no longer human.


Sources & References

  • React Documentation: Server Components — official reference on RSC rendering and data fetching
  • Next.js Documentation: Data Fetching — official guide to fetch caching and Server Actions in the App Router
  • Dan Abramov — React core team member writing on RSC design rationale
  • TanStack Query Documentation — reference for client-side reactive data fetching and caching, for the cases RSC doesn't cover
Newer Post

AI-Native Design Systems: Building for Agents

Older Post

Choosing the Right Engine: React Frameworks Beyond the Default

Suggested Reading

Architectural Note: Research, drafting, and code for this post were augmented by Gemini (Google DeepMind), directed and verified by Maas Mirzaa. How this workflow works →