DEV Community

Ukasha Mahir
Ukasha Mahir

Posted on

Beyond JSX: Modern Patterns for Building Scalable React Apps

Beyond JSX: Modern Patterns for Building Scalable React Apps

React has been the go‑to library for front‑end development for nearly a decade, but the ecosystem evolves fast. New hooks, concurrent rendering, and server‑side rendering (SSR) patterns make it possible to ship apps that feel snappy, are easy to maintain, and scale with your team. In this post we’ll walk through a pragmatic set of techniques you can adopt today to bring your React codebase from a collection of components to a robust, production‑ready architecture.

TL;DR – We’ll cover functional components, the power of custom hooks, React Query for data fetching, the new useId hook, and a quick dive into concurrent features with Suspense and useTransition.


1. Functional Components Are Here to Stay

Since React 16.8, functional components have eclipsed class components in almost every project. They’re lighter, easier to test, and unlock the full hook API. Let’s start with a simple component that fetches a list of users.

import React, { useEffect, useState } from 'react';

type User = { id: number; name: string; email: string };

export const UserList: React.FC = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users')
      .then(r => r.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      });
  }, []);

  if (loading) return <p>Loading…</p>;

  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name} – {u.email}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

Why this matters: The component is declarative, side‑effects are isolated in useEffect, and the UI updates automatically when state changes. No this, no binding, no lifecycle confusion.


2. Custom Hooks: Extracting Business Logic

When the same fetching logic appears in multiple places, duplicate code becomes a maintenance nightmare. Enter custom hooks—the perfect place to encapsulate reusable logic.

import { useState, useEffect } from 'react';

export function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(url)
      .then(r => r.json())
      .then(json => {
        if (!cancelled) {
          setData(json);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });
    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, error, loading };
}
Enter fullscreen mode Exit fullscreen mode

Now UserList becomes a thin wrapper:

import { useFetch } from './useFetch';

export const UserList: React.FC = () => {
  const { data: users, loading } = useFetch<User[]>('https://jsonplaceholder.typicode.com/users');

  if (loading) return <p>Loading…</p>;
  return (
    <ul>
      {users!.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Single source of truth for fetch handling.
  • Testable in isolation (just call the hook in a test component).
  • Composable – you can stack multiple hooks (e.g., pagination + caching).

3. Data Synchronization with React Query

While a custom hook works for simple cases, production apps often need caching, background refetching, and optimistic updates. React Query (now called TanStack Query) provides a battle‑tested solution.

import { useQuery } from '@tanstack/react-query';

export const UserList: React.FC = () => {
  const { data: users, isLoading, isError } = useQuery(['users'], async () => {
    const res = await fetch('https://jsonplaceholder.typicode.com/users');
    return res.json();
  });

  if (isLoading) return <p>Loading…</p>;
  if (isError) return <p>Failed to load users.</p>;

  return (
    <ul>
      {users!.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

Why switch?

  • Automatic cache invalidation (queryClient.invalidateQueries(['users'])).
  • Stale‑while‑revalidate patterns out of the box.
  • Built‑in retries and exponential back‑off.

4. The New useId Hook for Accessible UI

React 18 introduced useId, a server‑friendly way to generate unique IDs that stay consistent across renders. This is especially handy for form controls and ARIA attributes.

import { useId } from 'react';

export const SearchBox: React.FC = () => {
  const id = useId(); // e.g., :r0:
  return (
    <div>
      <label htmlFor={id}>Search</label>
      <input id={id} type="search" placeholder="Type something…" />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Before useId, you’d often see Math.random() or external libraries, both of which break SSR hydration because the IDs differ between server and client. useId guarantees a stable value, preventing “did not match” warnings.


5. Concurrent Rendering: Suspense + useTransition

React’s concurrent mode lets the UI stay responsive while heavy work runs in the background. Two primitives make it approachable:

Primitive Purpose
Suspense Declaratively wait for async data (or lazy components).
useTransition Mark UI updates as non‑urgent to keep the main thread free.

Example: Lazy‑loaded component with a fallback

import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

export const Dashboard: React.FC = () => {
  return (
    <section>
      <h2>Analytics</h2>
      <Suspense fallback={<p>Loading chart…</p>}>
        <HeavyChart />
      </Suspense>
    </section>
  );
};
Enter fullscreen mode Exit fullscreen mode

Example: UI transition for a filter list

import { useState, useTransition } from 'react';

export const FilteredList: React.FC = () => {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const filtered = items.filter(i => i.name.toLowerCase().includes(query.toLowerCase()));

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    startTransition(() => setQuery(value));
  };

  return (
    <>
      <input placeholder="Search…" onChange={handleChange} />
      {isPending && <span>Updating…</span>}
      <ul>
        {filtered.map(i => (
          <li key={i.id}>{i.name}</li>
        ))}
      </ul>
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

What’s happening? startTransition tells React that updating query is low priority. While the filter runs, React can still respond to clicks, scrolls, or input events, eliminating jank on large data sets.


6. TypeScript + React: A Safety Net

If you haven’t adopted TypeScript yet, now is the moment. It catches prop mismatches, enforces required fields, and improves IDE autocompletion. A quick tip: use the ComponentPropsWithoutRef utility to infer prop types from native elements.

import type { ComponentPropsWithoutRef } from 'react';

type ButtonProps = ComponentPropsWithoutRef<'button'> & {
  variant?: 'primary' | 'secondary';
};

export const Button = ({ variant = 'primary', ...rest }: ButtonProps) => (
  <button
    className={variant === 'primary' ? 'btn-primary' : 'btn-secondary'}
    {...rest}
  />
);
Enter fullscreen mode Exit fullscreen mode

Now any consumer who passes an unsupported prop (e.g., href on a <button>) gets a compile‑time error.


7. Testing React Components with React Testing Library

Testing is the final piece of a production‑ready React workflow. The community standard, React Testing Library (RTL), encourages testing from a user’s perspective.

// UserList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';
import { server, rest } from './testServer'; // msw

test('renders users after fetch', async () => {
  render(<UserList />);
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  await waitFor(() => expect(screen.getByText(/Leanne Graham/)).toBeInTheDocument());
});
Enter fullscreen mode Exit fullscreen mode

Key practices:

  • Avoid testing implementation details – query by role, label, or text.
  • Mock network calls with tools like MSW (Mock Service Worker) for realistic request/response cycles.
  • Keep tests fast; rely on React’s built‑in act wrapper provided by RTL.

8. Deploying with Vite + React Fast Refresh

Vite has become the de‑facto dev server for modern React projects because of its lightning‑fast hot module replacement (HMR) and out‑of‑the‑box support for JSX, TypeScript, and CSS modules.

npm create vite@latest my-react-app -- --template react-ts
cd my-react-app
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

Vite’s dev server serves source files over native ES modules, eliminating the heavy bundling step during development. Production builds still run through Rollup, delivering optimized bundles.


9. Scaling State Management

For small apps, useState and context are enough. As your app grows, consider Zustand or Jotai for lightweight global state, or Redux Toolkit if you need a predictable data flow and dev‑tools integration.

// zustandStore.ts
import { create } from 'zustand';

type Store = {
  count: number;
  inc: () => void;
};

export const useStore = create<Store>(set => ({
  count: 0,
  inc: () => set(state => ({ count: state.count + 1 })),
}));
Enter fullscreen mode Exit fullscreen mode

Then consume anywhere:

const Counter = () => {
  const { count, inc } = useStore();
  return <button onClick={inc}>Clicked {count} times</button>;
};
Enter fullscreen mode Exit fullscreen mode

Zustand’s API is minimal, making it a perfect middle ground between useContext and a full Redux setup.


10. Wrapping Up

React continues to evolve, but the core principles—declarative UI, component composition, and a focus on developer experience—remain unchanged. By embracing functional components, custom hooks, modern data‑fetching libraries, concurrent rendering, and TypeScript, you can build applications that are performant, maintainable, and future‑proof.

Happy coding! 🚀

Recommended Tools

Top comments (0)