Back to All Articles
reactstate-managementtypescriptarchitecture

Mastering React State: Practical Guide to Clean Architecture & Reducers

Learn how to structure React state cleanly, eliminate redundant variables, lift state up, and scale complex logic with useReducer.

2026-10-15
Mastering React State: Practical Guide to Clean Architecture & Reducers

Let's clean up messy component state and build scalable, bug-free React applications without falling into prop drilling or redundant data traps.

What you'll learn:

  • How to model visual states instead of mutating UI directly.
  • Why eliminating redundant state variables prevents subtle bugs.
  • How and when to lift state up or isolate it with useReducer.
  • Controlling component lifecycles and state resets using keys.

Thinking in Visual States

When building interactive UIs, stop thinking about individual commands like disableButton() or showError(). Instead, treat your component as a state machine. Define the explicit visual states—like 'typing', 'submitting', or 'success'—and let your UI derive itself directly from those values.

Here is how you handle a quiz form cleanly by keeping track of an overall status string:

TSX
import { useState, FormEvent, ChangeEvent } from 'react';

export default function CityQuiz() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState<Error | null>(null);
  const [status, setStatus] = useState<'typing' | 'submitting' | 'success'>('typing');

  if (status === 'success') {
    return <h1 className="text-xl font-bold text-green-600">That's right!</h1>;
  }

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    setStatus('submitting');
    setError(null);

    try {
      await submitForm(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err as Error);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="p-4 max-w-md space-y-4">
      <h2 className="text-lg font-semibold">City quiz</h2>
      <p className="text-sm text-gray-600">In which city is there a billboard that turns air into drinkable water?</p>
      
      <textarea
        value={answer}
        onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setAnswer(e.target.value)}
        disabled={status === 'submitting'}
        className="w-full p-2 border rounded"
      />

      <button
        type="submit"
        disabled={answer.length === 0 || status === 'submitting'}
        className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
      >
        Submit
      </button>

      {error && <p className="text-sm text-red-600">{error.message}</p>}
    </form>
  );
}

function submitForm(answer: string): Promise<void> {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.toLowerCase() !== 'lima') {
        reject(new Error('Good guess, but wrong. Try again!'));
      } else {
        resolve();
      }
    }, 1000);
  });
}

Speed Note: Grouping transient loading and error states into a single status union type prevents impossible UI states, like being simultaneously in 'submitting' and 'success' modes.

Eliminating Redundant State

A common source of bugs is storing computed values in state. If a piece of data can be calculated directly from existing state or props during render, drop the extra useState call entirely.

Here is how you avoid keeping a separate fullName state variable:

TSX
import { useState, ChangeEvent } from 'react';

export default function CheckInForm() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // Computed during render—always in sync, zero extra state updates.
  const fullName = `${firstName} ${lastName}`.trim();

  return (
    <div className="p-4 space-y-4 max-w-md">
      <label className="block">
        First name:
        <input
          value={firstName}
          onChange={(e: ChangeEvent<HTMLInputElement>) => setFirstName(e.target.value)}
          className="w-full mt-1 p-2 border rounded"
        />
      </label>
      <label className="block">
        Last name:
        <input
          value={lastName}
          onChange={(e: ChangeEvent<HTMLInputElement>) => setLastName(e.target.value)}
          className="w-full mt-1 p-2 border rounded"
        />
      </label>
      <p className="text-sm">
        Ticket issued to: <strong className="text-blue-600">{fullName || 'Anonymous'}</strong>
      </p>
    </div>
  );
}

Gotcha: If you store derived data in state, you introduce sync bugs where event handlers forget to update the secondary variable. Always compute on render when possible.

Lifting State Up to Share Between Components

When sibling components need to stay in sync, remove local state from both of them and move it to their closest common ancestor. Pass the value and a callback down through props.

Here is an accordion pattern where only one panel remains active at a time:

TSX
import { useState, ReactNode } from 'react';

export default function Accordion() {
  const [activeIndex, setActiveIndex] = useState<number>(0);

  return (
    <div className="p-4 space-y-3 max-w-md">
      <Panel
        title="About Almaty"
        isActive={activeIndex === 0}
        onShow={() => setActiveIndex(0)}
      >
        Almaty is Kazakhstan's largest city, serving as its capital from 1929 to 1997.
      </Panel>
      <Panel
        title="Etymology"
        isActive={activeIndex === 1}
        onShow={() => setActiveIndex(1)}
      >
        The name stems from 'alma', the Kazakh word for apple. The region is the ancestral home of wild apples.
      </Panel>
    </div>
  );
}

interface PanelProps {
  title: string;
  children: ReactNode;
  isActive: boolean;
  onShow: () => void;
}

function Panel({ title, children, isActive, onShow }: PanelProps) {
  return (
    <section className="border p-3 rounded bg-gray-50">
      <h3 className="font-medium">{title}</h3>
      {isActive ? (
        <p className="mt-2 text-sm text-gray-700">{children}</p>
      ) : (
        <button
          onClick={onShow}
          className="mt-2 px-3 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded"
        >
          Show
        </button>
      )}
    </section>
  );
}

Controlling Component Lifecycles with Keys

By default, React preserves component state across re-renders if the component position in the tree matches. When swapping context—like switching chat recipients—you often want to force a complete state reset.

Pass a unique key prop to instantly destroy and recreate the component instance:

TSX
import { useState } from 'react';

interface Contact {
  id: string;
  name: string;
  email: string;
}

const contacts: Contact[] = [
  { id: '1', name: 'Taylor', email: 'taylor@mail.com' },
  { id: '2', name: 'Alice', email: 'alice@mail.com' },
];

export default function Messenger() {
  const [to, setTo] = useState<Contact>(contacts[0]);

  return (
    <div className="flex gap-4 p-4">
      <ul className="space-y-2">
        {contacts.map((c) => (
          <li key={c.id}>
            <button
              onClick={() => setTo(c)}
              className={`px-3 py-1 text-sm rounded ${to.id === c.id ? 'bg-blue-600 text-white' : 'bg-gray-100'}`}
            >
              {c.name}
            </button>
          </li>
        ))}
      </ul>
      {/* Changing the key forces React to reset internal chat input state */}
      <Chat key={to.email} contact={to} />
    </div>
  );
}

function Chat({ contact }: { contact: Contact }) {
  const [text, setText] = useState('');
  return (
    <div className="border p-4 rounded flex-1">
      <h3 className="font-semibold mb-2">Chat with {contact.name}</h3>
      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder={`Message ${contact.email}...`}
        className="w-full p-2 border rounded text-sm"
      />
    </div>
  );
}

Accessibility: When resetting components via keys, ensure screen readers announce context shifts appropriately so assistive tech users aren't left stranded in stale views.

Consolidating Complex Logic with useReducer

When components grow numerous state updates scattered across multiple event handlers, extract that logic into a pure reducer function using useReducer.

TSX
import { useReducer, useState, FormEvent } from 'react';

interface Task {
  id: number;
  text: string;
  done: boolean;
}

type Action =
  | { type: 'added'; id: number; text: string }
  | { type: 'changed'; task: Task }
  | { type: 'deleted'; id: number };

function tasksReducer(tasks: Task[], action: Action): Task[] {
  switch (action.type) {
    case 'added':
      return [...tasks, { id: action.id, text: action.text, done: false }];
    case 'changed':
      return tasks.map((t) => (t.id === action.task.id ? action.task : t));
    case 'deleted':
      return tasks.filter((t) => t.id !== action.id);
    default:
      throw new Error('Unknown action type');
  }
}

let nextId = 3;
const initialTasks: Task[] = [
  { id: 0, text: 'Visit Kafka Museum', done: true },
  { id: 1, text: 'Watch puppet show', done: false },
  { id: 2, text: 'Walk across Lennon Wall', done: false },
];

export default function TaskApp() {
  const [tasks, dispatch] = useReducer(tasksReducer, initialTasks);
  const [text, setText] = useState('');

  function handleAdd(e: FormEvent) {
    e.preventDefault();
    if (!text.trim()) return;
    dispatch({ type: 'added', id: nextId++, text });
    setText('');
  }

  return (
    <div className="p-4 max-w-md space-y-4">
      <form onSubmit={handleAdd} className="flex gap-2">
        <input
          value={text}
          onChange={(e) => setText(e.target.value)}
          placeholder="New task..."
          className="flex-1 p-2 border rounded text-sm"
        />
        <button type="submit" className="px-3 py-2 bg-blue-600 text-white rounded text-sm">
          Add
        </button>
      </form>
      <ul className="space-y-2">
        {tasks.map((task) => (
          <li key={task.id} className="flex items-center justify-between p-2 border rounded text-sm">
            <label className="flex items-center gap-2">
              <input
                type="checkbox"
                checked={task.done}
                onChange={(e) => dispatch({ type: 'changed', task: { ...task, done: e.target.checked } })}
              />
              <span className={task.done ? 'line-through text-gray-400' : ''}>{task.text}</span>
            </label>
            <button
              onClick={() => dispatch({ type: 'deleted', id: task.id })}
              className="text-red-500 hover:underline text-xs"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

Summary & Next Steps

Structuring state intentionally eliminates entire classes of synchronization bugs. By modeling visual states, computing values on render, lifting state when sharing between siblings, and moving complex updates into reducers, your React apps remain maintainable at scale.

Next Steps:

  • Audit an existing component in your codebase to remove redundant state variables.
  • Refactor a complex cluster of useState hooks into a typed useReducer function.
  • Experiment with key props to handle clean state resets in dynamic layouts.