22 KiB
React guide
A lot of AI-generated React code is heavily biased toward using useState and useEffect, often in verbose or naive ways. That’s because these hooks are:
- Explicit, making them easy for LLMs to pattern-match.
- Safe defaults, requiring less architectural decision-making.
- Well-documented, so the model’s training data is oversaturated with examples.
But modern, well-architected React apps rarely rely on raw useState/useEffect beyond simple UI state or lifecycle quirks. Here’s a breakdown of what serious devs use instead—and why this is the real moat for human engineers.
Alternatives to useState and useEffect
- State Machines / Statecharts (e.g. Zustand)
- Why: Replaces ad-hoc local state management with deterministic, declarative logic.
- Advantage: Less bugs, easier to test, clearer state transitions.
- AI gap: LLMs struggle to model state transitions declaratively. LLM Agent must overcome this.
- React Query / SWR for Data Fetching
- Why: Avoids useEffect-based data fetching and loading/error state juggling.
- Advantage: Caching, stale-while-revalidate, retries, pagination—all handled out of the box.
- AI gap: AI often uses useEffect + fetch and forgets cleanup, race conditions, or caching.
- Global State with Zustand, Jotai, Recoil, or Redux Toolkit
- Why: Local useState doesn’t scale for shared or persistent state.
- Advantage: Better performance, devtools support, persistence.
- AI gap: Defaults to prop-drilling or bloated context APIs.
- Custom Hooks and Composition
- Why: Reuse logic cleanly without bloating components or copy-pasting useEffect.
- Advantage: Separation of concerns, encapsulation.
- AI gap: Often fails to factor logic out, keeping everything in one component.
Why This Matters
AI can mimic patterns, but it can’t:
- Architect a system for long-term maintainability.
- Predict runtime performance tradeoffs.
- Refactor spaghetti effects into custom hooks or state machines.
- Decide when state should be colocated vs globalized.
- Avoid footguns like stale closures, unnecessary re-renders, or race conditions.
In short: AI is great at writing code, but bad at engineering software.
Takeaway
- Use fewer useEffect and useState hooks.
- Learn to design systems, not just code components.
- Reach for declarative, composable patterns.
- Understand caching, reactivity, and scalability beyond local state.
Here’s a side-by-side breakdown of common AI-generated React patterns (bad) vs what experienced engineers write (good) across 4 key areas: state management, data fetching, side effects, and logic reuse.
1. State Management
❌ Bad (AI-style useState)
function TodoApp() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
const addTodo = () => {
setTodos([...todos, { text: input }]);
setInput('');
};
return (
<>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo, i) => <li key={i}>{todo.text}</li>)}
</ul>
</>
);
}
✅ Good (Zustand store, state extracted)
import { create } from 'zustand';
const useTodoStore = create((set) => ({
todos: [],
input: '',
addTodo: () => set((state) => ({
todos: [...state.todos, { text: state.input }],
input: '',
})),
setInput: (val) => set({ input: val }),
}));
function TodoApp() {
const { todos, input, setInput, addTodo } = useTodoStore();
return (
<>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo, i) => <li key={i}>{todo.text}</li>)}
</ul>
</>
);
}
2. Data Fetching
❌ Bad (AI-style useEffect)
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(setUsers);
}, []);
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
✅ Good (React Query)
import { useQuery } from '@tanstack/react-query';
function UserList() {
const { data: users = [], isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
});
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
3. Side Effects
❌ Bad (naive effect with race condition)
function Search({ term }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${term}`)
.then(res => res.json())
.then(setResults);
}, [term]);
return <div>{results.length} results</div>;
}
✅ Good (abort previous fetch with AbortController)
function Search({ term }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${term}`, { signal: controller.signal })
.then(res => res.json())
.then(setResults)
.catch((e) => {
if (e.name !== 'AbortError') console.error(e);
});
return () => controller.abort();
}, [term]);
return <div>{results.length} results</div>;
}
Better yet? Don’t use useEffect at all. Use React Query with term as a key.
4. Logic Reuse
❌ Bad (repeated logic inline)
function ComponentA() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
function ComponentB() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
✅ Good (custom hook)
function useCounter(intervalMs = 1000) {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return count;
}
function ComponentA() {
const count = useCounter();
return <div>{count}</div>;
}
function ComponentB() {
const count = useCounter();
return <div>{count}</div>;
}
5. State Machines with Zustand
❌ Bad (AI-style complex useState with boolean flags)
function OrderProcess() {
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [cart, setCart] = useState([]);
const [paymentDetails, setPaymentDetails] = useState(null);
const [orderPlaced, setOrderPlaced] = useState(false);
const addToCart = (item) => {
setCart([...cart, item]);
};
const submitPayment = async (details) => {
setIsLoading(true);
setIsError(false);
try {
await processPayment(details);
setPaymentDetails(details);
setIsSuccess(true);
setIsLoading(false);
} catch (error) {
setIsError(true);
setIsLoading(false);
}
};
const placeOrder = async () => {
setIsLoading(true);
setIsError(false);
try {
await submitOrder(cart, paymentDetails);
setOrderPlaced(true);
setIsSuccess(true);
setIsLoading(false);
} catch (error) {
setIsError(true);
setIsLoading(false);
}
};
return (
<div>
{/* Complex conditional rendering based on multiple state variables */}
{isLoading && <LoadingSpinner />}
{isError && <ErrorMessage />}
{!isLoading && !isError && !orderPlaced && (
<>
<CartItems items={cart} onAddItem={addToCart} />
<PaymentForm onSubmit={submitPayment} />
{paymentDetails && <OrderButton onClick={placeOrder} />}
</>
)}
{orderPlaced && <OrderConfirmation />}
</div>
);
}
✅ Good (Zustand state machine)
import { create } from 'zustand';
// Create a Zustand store with state machine pattern
const useOrderStore = create((set, get) => ({
// State machine's current state
state: 'browsing',
// Context/data
cart: [],
paymentDetails: null,
error: null,
// Actions that transition between states
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
goToCheckout: () => set({ state: 'paymentEntry' }),
goBack: () => {
const { state: currentState } = get();
if (currentState === 'paymentEntry') {
set({ state: 'browsing' });
} else if (currentState === 'confirmOrder') {
set({ state: 'paymentEntry' });
}
},
submitPayment: async (details) => {
set({ state: 'processingPayment', error: null });
try {
const result = await processPayment(details);
set({
state: 'confirmOrder',
paymentDetails: result
});
} catch (error) {
set({
state: 'paymentEntry',
error: error.message
});
}
},
placeOrder: async () => {
const { cart, paymentDetails } = get();
set({ state: 'processingOrder', error: null });
try {
await submitOrder(cart, paymentDetails);
set({ state: 'orderComplete' });
} catch (error) {
set({
state: 'confirmOrder',
error: error.message
});
}
}
}));
function OrderProcess() {
const {
state,
cart,
paymentDetails,
error,
addToCart,
goToCheckout,
goBack,
submitPayment,
placeOrder
} = useOrderStore();
// Render UI based on current state
return (
<div>
{error && <ErrorMessage message={error} />}
{state === 'browsing' && (
<CartItems
items={cart}
onAddItem={addToCart}
onCheckout={goToCheckout}
/>
)}
{state === 'paymentEntry' && (
<PaymentForm
onSubmit={submitPayment}
onBack={goBack}
/>
)}
{state === 'processingPayment' && (
<LoadingSpinner message="Processing payment..." />
)}
{state === 'confirmOrder' && (
<OrderSummary
cart={cart}
paymentDetails={paymentDetails}
onConfirm={placeOrder}
onBack={goBack}
/>
)}
{state === 'processingOrder' && (
<LoadingSpinner message="Placing your order..." />
)}
{state === 'orderComplete' && (
<OrderConfirmation orderDetails={{ cart, paymentDetails }} />
)}
</div>
);
}
Key Benefits of the Zustand State Machine Approach:
- Explicit states: The system can only be in one well-defined state at a time
- Centralized logic: All state transitions are defined in one place
- Predictable transitions: State changes follow clear patterns
- Simplified UI logic: Components just render based on the current state
- Async handling: Asynchronous operations are contained within the state transitions
- Shared state: Any component can access the store without prop drilling
- Improved debugging: The current state is always clear and predictable
- No impossible states: Unlike boolean flags that could create invalid combinations
- Simpler API: Compared to XState, Zustand offers a more approachable API for many developers
- Smaller bundle size: Zustand is significantly smaller than XState
This approach gives most of the benefits of formal state machines while maintaining the simplicity and familiarity of React state management. It's a pragmatic middle ground that's often easier to adopt in existing projects.
6. Concurrent UI Techniques
❌ Bad (Blocking UI updates without concurrency):
// Bad example: Synchronously fetching data in useEffect can cause UI jank.
function SearchResults({ query }) {
const [results, setResults] = React.useState([]);
React.useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
return (
<div>
{results.map(item => <p key={item.id}>{item.title}</p>)}
</div>
);
}
✅ Good (Using useTransition for non-blocking updates):
// Good example: Leveraging useTransition to mark state updates as low-priority.
function SearchResults({ query }) {
const [results, setResults] = React.useState([]);
const [isPending, startTransition] = React.useTransition();
React.useEffect(() => {
startTransition(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
});
}, [query, startTransition]);
return (
<div>
{isPending && <div>Loading...</div>}
{results.map(item => <p key={item.id}>{item.title}</p>)}
</div>
);
}
7. Routing and Navigation (Not applicable with Next.js apps)
❌ Bad (Manual routing without a dedicated library):
// Bad example: Handling routing manually using state and window history.
function App() {
const [route, setRoute] = React.useState(window.location.pathname);
React.useEffect(() => {
const onPopState = () => setRoute(window.location.pathname);
window.addEventListener('popstate', onPopState);
return () => window.removeEventListener('popstate', onPopState);
}, []);
return (
<div>
{route === '/' && <Home />}
{route === '/about' && <About />}
<button onClick={() => {
window.history.pushState({}, '', '/about');
setRoute('/about');
}}>
About
</button>
</div>
);
}
✅ Good (Using React Router v6 with lazy-loaded routes):
// Good example: Utilizing React Router to manage routes and lazy load components.
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import React, { lazy, Suspense } from 'react';
const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
8. Error Handling
❌ Bad (No error boundary; crashes on errors):
// Bad example: A component that throws errors without an error boundary.
function MyComponent({ data }) {
if (!data) {
throw new Error("Data not found");
}
return <div>{data}</div>;
}
function App() {
return <MyComponent data={null} />;
}
✅ Good (Wrapping components with an Error Boundary):
// Good example: Using an ErrorBoundary component to catch and handle errors gracefully.
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("Error caught in boundary:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
function MyComponent({ data }) {
if (!data) {
throw new Error("Data not found");
}
return <div>{data}</div>;
}
function App() {
return (
<ErrorBoundary>
<MyComponent data={null} />
</ErrorBoundary>
);
}
9. Form Management
❌ Bad (Manual state handling with no validation):
// Bad example: Uncontrolled form without validation; error handling is omitted.
function ContactForm() {
const [formData, setFormData] = React.useState({ name: '', email: '' });
const handleSubmit = (e) => {
e.preventDefault();
console.log(formData);
};
return (
<form onSubmit={handleSubmit}>
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Name"
/>
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
);
}
✅ Good (Using React Hook Form with Zod for schema validation):
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. Define your schema with Zod
const contactSchema = z.object({
name: z.string().nonempty('Name is required'),
email: z
.string()
.nonempty('Email is required')
.email('Invalid email'),
});
// 2. Infer the TypeScript type of your form data
type ContactFormData = z.infer<typeof contactSchema>;
export function ContactForm() {
// 3. Hook up RHF with the zodResolver
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
const onSubmit = (data: ContactFormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
{...register('name')}
placeholder="Name"
aria-invalid={!!errors.name}
/>
{errors.name && <p role="alert">{errors.name.message}</p>}
</div>
<div>
<input
{...register('email')}
placeholder="Email"
aria-invalid={!!errors.email}
/>
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<button type="submit">Submit</button>
</form>
);
}
10. Code Splitting and Lazy Loading
❌ Bad (Eagerly importing heavy components, increasing the initial bundle size):
// Bad example: Directly importing a large component causing a heavier bundle.
import HeavyComponent from './HeavyComponent';
function App() {
return (
<div>
<HeavyComponent />
</div>
);
}
✅ Good (Lazy loading heavy components with React.lazy and Suspense):
// Good example: Dynamically importing components to reduce initial load time.
import React, { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading Component...</div>}>
<HeavyComponent />
</Suspense>
);
}
11. Testing Strategies
❌ Bad (Testing implementation details, resulting in brittle tests):
// Bad example: Relying on internal DOM structure which may change.
import { render } from '@testing-library/react';
import MyComponent from './MyComponent';
test('MyComponent renders data', () => {
const { container } = render(<MyComponent />);
expect(container.querySelector('.data')).toBeDefined();
});
✅ Good (Focusing on user-centric behaviors using React Testing Library):
// Good example: Testing component behavior by simulating user interactions.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MyComponent from './MyComponent';
test('MyComponent displays loaded data after user action', async () => {
render(<MyComponent />);
// Check for an initial loading state
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
// Simulate user action that triggers data loading
const button = screen.getByRole('button', { name: /load data/i });
userEvent.click(button);
// Wait and assert that data is displayed
expect(await screen.findByText(/Data loaded/i)).toBeInTheDocument();
});
12. Accessibility & Theming
❌ Bad (Non-semantic elements and missing ARIA labels):
// Bad example: Using a div as a clickable element without semantics or accessibility.
function ThemedButton() {
return <div onClick={() => console.log('Clicked!')}>Click me</div>;
}
✅ Good (Semantic button with ARIA attributes and theming via CSS variables):
// Good example: Accessible button component that utilizes theming.
function ThemedButton() {
return (
<button
aria-label="Click me"
onClick={() => console.log('Clicked!')}
style={{ padding: '8px 16px', backgroundColor: 'var(--primary-color)', color: '#fff' }}
>
Click me
</button>
);
}
/* CSS (in a separate file or styled-components):
:root {
--primary-color: #007bff;
}
*/
Summary Table
| Concern | Bad AI Pattern | Solid Engineering Practice |
|---|---|---|
| State Management | useState everywhere, prop‑drilling |
Central store (Zustand / Redux Toolkit) or colocated custom hooks |
| Data Fetching | useEffect + fetch, manual loading/error juggling |
React Query / SWR with caching, retries, stale‑while‑revalidate |
| Side Effects | Effects without cleanup → race conditions, leaks | AbortController or query library handles cancellation & retries |
| Logic Reuse | Copy‑pasted hooks and state in every component | Extract cross‑cutting logic into custom hooks |
| Workflow / State Machine | Boolean‑flag soup, impossible states | Deterministic statechart in a single store (Zustand / XState) |
| Concurrent UI Updates | Blocking synchronous work inside effects | useTransition / startTransition for low‑priority updates |
| Routing & Navigation | DIY history manipulation, inline route state | React Router v6 (or Next.js built‑ins) with lazy‑loaded routes |
| Error Handling | No error boundary → full‑app crashes | Top‑level ErrorBoundary plus logging in componentDidCatch |
| Form Management | Manual state, zero validation or feedback | React Hook Form + Yup/Zod schema for declarative validation |
| Code Splitting & Lazy Loading | Eager imports ballooning bundle size | React.lazy + Suspense for on‑demand loading |
| Testing Strategy | Brittle tests keyed to DOM structure | User‑centric tests with React Testing Library & jest‑dom |
| Accessibility & Theming | Non‑semantic elements, missing ARIA, inline colors | Semantic HTML, ARIA labels, CSS variables / theme provider |