Files
seedit/docs/react-guide.md
plebeius e16d0537a4 add docs
2026-01-04 12:32:30 +01:00

22 KiB
Raw Permalink Blame History

React guide

A lot of AI-generated React code is heavily biased toward using useState and useEffect, often in verbose or naive ways. Thats because these hooks are:

  1. Explicit, making them easy for LLMs to pattern-match.
  2. Safe defaults, requiring less architectural decision-making.
  3. Well-documented, so the models 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. Heres a breakdown of what serious devs use instead—and why this is the real moat for human engineers.

Alternatives to useState and useEffect

  1. 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.
  1. 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.
  1. Global State with Zustand, Jotai, Recoil, or Redux Toolkit
  • Why: Local useState doesnt scale for shared or persistent state.
  • Advantage: Better performance, devtools support, persistence.
  • AI gap: Defaults to prop-drilling or bloated context APIs.
  1. 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 cant:

  • 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.

Heres 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? Dont 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:

  1. Explicit states: The system can only be in one well-defined state at a time
  2. Centralized logic: All state transitions are defined in one place
  3. Predictable transitions: State changes follow clear patterns
  4. Simplified UI logic: Components just render based on the current state
  5. Async handling: Asynchronous operations are contained within the state transitions
  6. Shared state: Any component can access the store without prop drilling
  7. Improved debugging: The current state is always clear and predictable
  8. No impossible states: Unlike boolean flags that could create invalid combinations
  9. Simpler API: Compared to XState, Zustand offers a more approachable API for many developers
  10. 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, propdrilling Central store (Zustand / Redux Toolkit) or colocated custom hooks
Data Fetching useEffect+fetch, manual loading/error juggling React Query / SWR with caching, retries, stalewhilerevalidate
Side Effects Effects without cleanup → race conditions, leaks AbortController or query library handles cancellation & retries
Logic Reuse Copypasted hooks and state in every component Extract crosscutting logic into custom hooks
Workflow / State Machine Booleanflag soup, impossible states Deterministic statechart in a single store (Zustand / XState)
Concurrent UI Updates Blocking synchronous work inside effects useTransition / startTransition for lowpriority updates
Routing & Navigation DIY history manipulation, inline route state React Router v6 (or Next.js builtins) with lazyloaded routes
Error Handling No error boundary → fullapp crashes Toplevel 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 ondemand loading
Testing Strategy Brittle tests keyed to DOM structure Usercentric tests with React Testing Library & jestdom
Accessibility & Theming Nonsemantic elements, missing ARIA, inline colors Semantic HTML, ARIA labels, CSS variables / theme provider