Explore Blogs

Can you build an entire react app without state management?

bpi-react-apps-without-state-management-tools

In traditional React development, state management often feels like a must-have, but it doesn't always need to be. By relying on URL parameters, derived props, and intelligent server-side caching, you can build a React app that remains highly functional without the complexity of global state management.

You've probably heard it before: "React is all about state". Every tutorial drills useState into your brain like it's oxygen for components. Then comes Redux, Zustand, Recoil, Jotai… suddenly your project is a state management warzone with a dozen layers of indirection and too many Provider wrappers to count.

But… what if you didn't need any of them?

No Redux. No Zustand. Not even a single useReducer.
What if you could build a full-blown React app with no client-side state management at all?

Turns out, you can.

Reframing the problem: Do you need local state?

Before reaching for a state library, ask yourself:
"What am I managing, and why?"

Most apps aren't spreadsheets. They're UIs for navigating data, triggering mutations, and showing different views based on user choices. And a lot of that can be boiled down to:

  • What's in the URL?
  • What props does this component derive?
  • What does the server already know and cache?

With those three tools — URL, derived props, and smart server caching — you can eliminate the need for 80–90% of client-side state.

Let's walk through how.

Let the URL carry the state

The URL isn't just for navigation — it's your global state container.

  • Page? It's in the path.
  • Tab open? Query param.
  • Filters applied? Also query params.
  • Sort order? You guessed it.

This makes your app instantly:

  • Shareable (just copy the link),
  • Bookmarked (open the app exactly where you left off),
  • Restorable (refresh doesn't kill your state),
  • Debuggable (easy to trace user flows).
// useSearchParams from react-router-dom
const [params] = useSearchParams();
const tab = params.get('tab') || 'overview';

Want to switch tabs? Update the URL.

const switchTab = (newTab) => {
  const next = new URLSearchParams(params);
  next.set('tab', newTab);
  navigate(`?${next.toString()}`);
};

You don't need useState. The browser is your state machine.

Derive everything you can from props

Think of React components as functions.
Functions don't hold state — they receive inputs and return outputs.

That's all props are.

Instead of storing selected filters, compute them from the current URL and pass them down.
Instead of storing "is modal open", let routing decide if you're on a /modal/:id path.

Components don't need to know how data changes — just what to render.

function ProductList({ filters }) {
  const filteredProducts = useMemo(() => applyFilters(dataFromServer, filters), [dataFromServer, filters]);

  return <RenderList items={filteredProducts} />;
}

If your logic depends on filters, and filters come from the URL — you've achieved pure derivation. No state management necessary.

Cache smartly on the server

Here's the secret weapon:
State belongs closer to where data lives.

Why send filters to the client and compute everything in JS, when your server (or edge function) can return the perfect response?

GET /products?category=shoes&sort=price_asc
Cache-Control: public, max-age=60

Your React app becomes a simple renderer. The server handles logic. Cloudflare (or your CDN) caches it.

This gives you:

  • Automatic deduplication.
  • Fast loads across tabs.
  • Predictable behavior.
  • Stateless components.

Bonus: it scales better.

Your server knows how to interpret URLs. It doesn't care if 10,000 users hit the same URL — it'll serve the same cached response instantly.

When this pattern makes sense

This approach isn't a fit for every app. But it shines in:

  • Content-heavy apps
  • Dashboards with lots of filters
  • Admin panels
  • Multi-step wizards (step = URL)
  • Read-heavy UIs with occasional mutations

Don't use this if:

  • You're building a real-time collaborative tool
  • You have deep client-only logic with long-lived in-memory state
  • You need offline support or optimistic updates

Here are few examples using the URL as State

Pagination with URL params

// /products?page=2
const [params] = useSearchParams();
const page = parseInt(params.get('page') || '1', 10);

// Fetch server-rendered paginated results
useEffect(() => {
  fetch(`/api/products?page=${page}`)
    .then((res) => res.json())
    .then(setProducts);
}, [page]);
  • No local useState needed.
  • Works with refresh, share, and bookmarks.
  • Can be cached per page (e.g., Cloudflare Cache-Key can vary by page).

Derived props from parent context

Let's say you're building a dashboard with filters, sorting, and tabs.

function DashboardPage() {
  const [params] = useSearchParams();
  const filters = {
    category: params.get('category') || 'all',
    sort: params.get('sort') || 'popular',
    tab: params.get('tab') || 'overview',
  };

  return (
    <DashboardLayout>
      <Filters filters={filters} />
      <DashboardContent filters={filters} />
    </DashboardLayout>
  );
}

Every child gets exactly what it needs from props. No global state. The logic is composable and testable.

Avoiding re-renders with memoized derived state

Heavy computations? Still don't need global state:

const computedResults = useMemo(() => {
  return expensiveLogic(data, filters);
}, [data, filters]);

Want to cache across components? Use a memoized selector in a custom hook.

const useFilteredData = (filters) => {
  const data = useContext(ServerDataContext);
  return useMemo(() => applyFilters(data, filters), [data, filters]);
};

No state library. No context re-renders. Just pure derivation.

Building a real feature – Product search

Let's say we want to build a full search experience with no local state.

  • URL contains the search query
  • Server returns results based on that query
  • UI renders results based on fetched data
function ProductSearchPage() {
  const [params, setParams] = useSearchParams();
  const query = params.get('q') || '';

  const [results, setResults] = useState([]);

  useEffect(() => {
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then(setResults);
  }, [query]);

  return (
    <>
      <SearchInput
        defaultValue={query}
        onSubmit={(q) => {
          setParams({ q });
        }}
      />
      <ProductList items={results} />
    </>
  );
}

Even though we used useState for results (we could drop this too using SWR or React Query), there's still no traditional "state management." Everything is driven by the URL and server.

Combine this with SWR / React Query for stateless data caching

What if you still need client caching — but not global state?

const { data, isLoading } = useSWR(`/api/items?filter=${filter}`);

Let the library manage the fetch lifecycle. No need for:

  • useReducer
  • Contexts
  • State sync
  • Manual loading/error handling

Pair this with URL-driven state, and you’re managing app behavior with:

  • The URL
  • Derived props
  • Cached async fetches

That's a stateless UI with a reactive feel.


Server caching: HTTP headers matter

If the server is part of your state system, you need to treat it like a cache.

Cache-Control: public, max-age=60, stale-while-revalidate=120

This ensures:

  • Requests for the same filter URL hit cache
  • No wasteful recomputation
  • Faster UX on repeat views

Bonus? You can do SSR or static HTML caching easily. You don’t need a client store for consistency — your URLs drive identity.

Don't go stateless just to be cool

Here's a reality check: no state management works only if your app doesn't need client-side control. This approach:

Works when:

  • The server handles most logic
  • You want to lean on cache
  • You care about shareable/bookmarkable flows

Doesn't work when:

  • You need complex interactions across unrelated components
  • You need in-memory workflows like form builders, whiteboards, or games
  • You’re building real-time UIs with local coordination (chats, multiplayer)

What you gain by skipping state management

Zero boilerplate. No reducers, actions, or global stores.
Better UX. URLs reflect UI. Refresh-safe. Shareable.
Less testing overhead. No need to mock stores.
Performance wins. Server-rendered, cacheable, fast.
Simpler mental model. What you see is what you derive.

You focus on views and behavior. Not wiring.

The state of no state

React is powerful because of its flexibility. But with great power comes... complicated app trees, if you're not careful.

Sometimes, the best state management is no state at all.
Just props, URLs, and smart backends.

If you haven't tried it — give it a shot.
It'll change the way you think about React apps.

Building a React app without traditional state management might sound counterintuitive, but when you rely on URLs, derived props, and server-side caching, you can create scalable, performant, and maintainable applications. By focusing on what's essential for your UI and leveraging the power of the browser and server, you can cut down on complexity and avoid unnecessary libraries. For content-heavy and read-heavy apps, this approach provides simplicity without sacrificing functionality.

Stay Updated

This site is protected by reCAPTCHA and the GooglePrivacy Policy andTerms of Service apply.