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
useIdhook, and a quick dive into concurrent features withSuspenseanduseTransition.
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>
);
};
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 };
}
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>
);
};
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>
);
};
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>
);
};
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>
);
};
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>
</>
);
};
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}
/>
);
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());
});
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
actwrapper 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
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 })),
}));
Then consume anywhere:
const Counter = () => {
const { count, inc } = useStore();
return <button onClick={inc}>Clicked {count} times</button>;
};
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
- React Developer Tools – Inspect component trees, view hook state, and debug performance directly in Chrome or Firefox. Grab the latest version from the official site: https://react-devtools.example.com/affiliate?ref=blogpost
- Vite – Super‑fast dev server and bundler for modern React projects. Get started quickly with the CLI starter kit: https://vitejs.dev/affiliate?utm_source=devto_blog
Top comments (0)