Component State Management Patterns

1. Local State with useState and useReducer

Pattern When to Use Pros Cons
useState Simple independent state values Simple API, easy to understand, minimal boilerplate Can get messy with complex state
useReducer Complex state logic, multiple sub-values Centralized logic, predictable updates, testable More boilerplate, steeper learning curve
Multiple useState Unrelated state pieces Clear separation of concerns Many setters, potential for mistakes
Object State Related properties that update together Single state object, atomic updates Must spread previous state carefully
State Initializer Expensive initial computation Computed only once Function call syntax needed

Example: Choosing between useState and useReducer

// useState for simple state
const SimpleCounter = () => {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

// Multiple useState for independent values
const UserForm = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState(0);
  
  // Each state is independent and clear
  return (/* form */);
};

// useState with object for related data
const ProfileEditor = () => {
  const [profile, setProfile] = useState({
    name: '',
    email: '',
    bio: ''
  });
  
  const updateField = (field, value) => {
    setProfile(prev => ({ ...prev, [field]: value }));
  };
  
  return (/* editor */);
};

// useReducer for complex state logic
const ShoppingCart = () => {
  const [state, dispatch] = useReducer(cartReducer, {
    items: [],
    total: 0,
    discount: 0,
    tax: 0
  });
  
  return (
    <div>
      <button onClick={() => dispatch({ 
        type: 'ADD_ITEM', 
        payload: item 
      })}>
        Add to Cart
      </button>
      <button onClick={() => dispatch({ type: 'APPLY_DISCOUNT' })}>
        Apply Discount
      </button>
      <button onClick={() => dispatch({ type: 'CALCULATE_TOTAL' })}>
        Calculate Total
      </button>
    </div>
  );
};

// Decision tree:
// - 1 value, simple updates? → useState
// - Few independent values? → multiple useState
// - Related values updating together? → useState with object
// - Complex logic, state depends on actions? → useReducer
// - State transitions like a state machine? → useReducer

2. State Lifting and Sharing Between Components

Technique Description Use Case Trade-off
Lift State Up Move state to common parent component Share state between sibling components Parent re-renders on every update
Prop Drilling Pass state through component tree Simple component hierarchies Tedious with deep nesting
Inverse Data Flow Child updates parent via callbacks Child needs to modify parent state Callback props everywhere
Composition Pass components as children/props Avoid drilling through wrappers Requires planning component structure
Context Global state without prop drilling Deep nesting, many consumers Can cause unnecessary re-renders

Example: State lifting patterns

// Before: State in child components (not shared)
const TemperatureInput1 = () => {
  const [temp, setTemp] = useState('');
  return <input value={temp} onChange={(e) => setTemp(e.target.value)} />;
};

// After: Lift state to parent
const TemperatureConverter = () => {
  const [celsius, setCelsius] = useState('');
  
  const fahrenheit = celsius ? (celsius * 9/5 + 32).toFixed(1) : '';
  
  return (
    <div>
      <TemperatureInput
        scale="Celsius"
        temperature={celsius}
        onTemperatureChange={setCelsius}
      />
      <TemperatureInput
        scale="Fahrenheit"
        temperature={fahrenheit}
        onTemperatureChange={(f) => setCelsius(((f - 32) * 5/9).toFixed(1))}
      />
      <p>Boiling: {celsius >= 100 ? 'Yes' : 'No'}</p>
    </div>
  );
};

const TemperatureInput = ({ scale, temperature, onTemperatureChange }) => (
  <div>
    <label>{scale}:</label>
    <input
      value={temperature}
      onChange={(e) => onTemperatureChange(e.target.value)}
    />
  </div>
);

// Composition to avoid prop drilling
const Layout = ({ header, sidebar, content }) => (
  <div>
    <header>{header}</header>
    <div className="main">
      <aside>{sidebar}</aside>
      <main>{content}</main>
    </div>
  </div>
);

// Usage: Pass components with their own state
const App = () => {
  const [user, setUser] = useState(null);
  
  return (
    <Layout
      header={<Header user={user} onLogout={() => setUser(null)} />}
      sidebar={<Sidebar />} {/* No user prop needed */}
      content={<Dashboard user={user} />}
    />
  );
};

// When to lift state:
// ✓ Two+ components need same data
// ✓ Data needs to be synchronized
// ✓ Parent needs to coordinate children
// ✗ Only one component uses it (keep local)
// ✗ Performance issues (consider Context/memo)

3. State Colocation and Component Responsibility

Principle Description Benefit Example
State Colocation Keep state as close to where it's used Better performance, easier maintenance Modal open state in Modal component
Single Responsibility Component manages its own concerns Reusable, testable, maintainable Form validates its own fields
Lift on Demand Start local, lift only when needed Avoid premature optimization Lift when sibling needs access
Encapsulation Hide implementation details Flexibility to change internals Accordion manages panel states
Separation of Concerns Split unrelated state into components Independent testing and changes Filter state vs display state

Example: State colocation best practices

// ❌ Bad: State too high in tree
const App = () => {
  const [modalOpen, setModalOpen] = useState(false);
  const [tooltipOpen, setTooltipOpen] = useState(false);
  const [accordionIndex, setAccordionIndex] = useState(0);
  
  return (
    <div>
      <Modal open={modalOpen} onClose={() => setModalOpen(false)} />
      <Tooltip open={tooltipOpen} />
      <Accordion activeIndex={accordionIndex} />
    </div>
  );
};

// ✓ Good: State colocated with components
const Modal = () => {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <>
      <button onClick={() => setIsOpen(true)}>Open</button>
      {isOpen && (
        <div className="modal">
          <button onClick={() => setIsOpen(false)}>Close</button>
        </div>
      )}
    </>
  );
};

// ✓ Good: Component responsibility
const SearchableList = ({ items }) => {
  // Search state belongs here
  const [query, setQuery] = useState('');
  const [sortOrder, setSortOrder] = useState('asc');
  
  const filteredItems = useMemo(() => {
    return items
      .filter(item => item.name.includes(query))
      .sort((a, b) => sortOrder === 'asc' 
        ? a.name.localeCompare(b.name)
        : b.name.localeCompare(a.name)
      );
  }, [items, query, sortOrder]);
  
  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button onClick={() => setSortOrder(prev => 
        prev === 'asc' ? 'desc' : 'asc'
      )}>
        Sort
      </button>
      <List items={filteredItems} />
    </div>
  );
};

// ✓ Good: Lift only when necessary
const ProductPage = () => {
  // Shared between multiple components
  const [selectedProduct, setSelectedProduct] = useState(null);
  
  return (
    <div>
      <ProductList onSelect={setSelectedProduct} />
      <ProductDetails product={selectedProduct} />
      <RelatedProducts productId={selectedProduct?.id} />
    </div>
  );
};

// Guidelines:
// 1. Start with local state
// 2. Lift when siblings need to share
// 3. Keep state close to usage
// 4. Each component owns its UI state
Note: State colocation improves performance because only components that need the state will re-render. Avoid lifting state too early—do it when actually needed.

4. Derived State and Computed Values

Technique Implementation Use Case Performance
Inline Calculation const total = items.reduce(...) Simple, cheap calculations Recalculated every render
useMemo useMemo(() => calc, [deps]) Expensive computations Cached between renders
Avoid Sync State Don't copy props to state Prevent sync issues No duplication overhead
Controlled Derivation Derive in render, don't store Always in sync with source Depends on calculation cost
Key Reset Pattern <Comp key={id} /> Reset state when prop changes Component remounts

Example: Derived state patterns

// ✓ Good: Derive inline (cheap calculation)
const CartSummary = ({ items }) => {
  const total = items.reduce((sum, item) => sum + item.price, 0);
  const itemCount = items.length;
  const tax = total * 0.1;
  
  return (
    <div>
      <p>Items: {itemCount}</p>
      <p>Subtotal: ${total}</p>
      <p>Tax: ${tax}</p>
      <p>Total: ${total + tax}</p>
    </div>
  );
};

// ✓ Good: useMemo for expensive calculations
const DataTable = ({ data, filters }) => {
  const filteredData = useMemo(() => {
    return data
      .filter(row => filters.every(f => f(row)))
      .sort((a, b) => a.name.localeCompare(b.name))
      .map(row => transformRow(row));
  }, [data, filters]);
  
  return <table>{/* render filteredData */}</table>;
};

// ❌ Bad: Syncing props to state
const SearchInput = ({ initialValue }) => {
  // DON'T DO THIS
  const [value, setValue] = useState(initialValue);
  // value won't update when initialValue changes!
  
  return <input value={value} onChange={(e) => setValue(e.target.value)} />;
};

// ✓ Good: Controlled component
const SearchInput = ({ value, onChange }) => {
  return <input value={value} onChange={onChange} />;
};

// ✓ Good: Key reset pattern when prop changes
const UserProfile = ({ userId }) => {
  return <ProfileForm key={userId} userId={userId} />;
  // ProfileForm remounts and resets state when userId changes
};

// ❌ Bad: Storing derived state
const FilteredList = ({ items, filter }) => {
  const [filteredItems, setFilteredItems] = useState([]);
  
  // This creates sync issues!
  useEffect(() => {
    setFilteredItems(items.filter(filter));
  }, [items, filter]);
  
  return <List items={filteredItems} />;
};

// ✓ Good: Derive on every render
const FilteredList = ({ items, filter }) => {
  const filteredItems = useMemo(
    () => items.filter(filter),
    [items, filter]
  );
  
  return <List items={filteredItems} />;
};

// Rule: If you can calculate it from props/state, don't store it!
Warning: Never copy props to state unless you explicitly want to ignore prop updates. This causes sync bugs. Derive values instead or use the key prop to reset component state.

5. State Normalization for Complex Data

Pattern Structure Benefit Use Case
Normalized State { byId: {}, allIds: [] } O(1) lookups, no duplication Large datasets, frequent updates
Flat Structure Avoid deep nesting Easier updates, better performance Nested entities
Entity Tables Separate entities by type Clear organization, type safety Multiple entity types
Reference by ID Store IDs instead of objects Single source of truth Relationships between entities
Selector Functions Compute derived views Reusable query logic Complex filtering/sorting

Example: State normalization patterns (Part 1/2)

// ❌ Bad: Nested, duplicated data
const badState = {
  posts: [
    {
      id: 1,
      title: 'Post 1',
      author: { id: 1, name: 'Alice' },
      comments: [
        { id: 1, text: 'Comment', author: { id: 2, name: 'Bob' } }
      ]
    }
  ]
};
// Problems: Deep nesting, author duplicated, hard to update

// ✓ Good: Normalized structure
const normalizedState = {
  users: {
    byId: {
      1: { id: 1, name: 'Alice' },
      2: { id: 2, name: 'Bob' }
    },
    allIds: [1, 2]
  },
  posts: {
    byId: {
      1: { id: 1, title: 'Post 1', authorId: 1, commentIds: [1] }
    },
    allIds: [1]
  },
  comments: {
    byId: {
      1: { id: 1, text: 'Comment', authorId: 2, postId: 1 }
    },
    allIds: [1]
  }
};

// Reducer for normalized state
const postsReducer = (state, action) => {
  switch (action.type) {
    case 'ADD_POST':
      return {
        ...state,
        byId: {
          ...state.byId,
          [action.payload.id]: action.payload
        },
        allIds: [...state.allIds, action.payload.id]
      };
      
    case 'UPDATE_POST':
      return {
        ...state,
        byId: {
          ...state.byId,
          [action.payload.id]: {
            ...state.byId[action.payload.id],
            ...action.payload.updates
          }
        }
      };
      
    case 'DELETE_POST':
      const { [action.payload]: deleted, ...remainingById } = state.byId;
      return {
        byId: remainingById,
        allIds: state.allIds.filter(id => id !== action.payload)
      };
      
    default:
      return state;
  }
};

Example: State normalization patterns (Part 2/2)

// Selector functions
const selectPostById = (state, postId) => state.posts.byId[postId];

const selectPostWithAuthor = (state, postId) => {
  const post = state.posts.byId[postId];
  const author = state.users.byId[post.authorId];
  return { ...post, author };
};

const selectPostsWithComments = (state) => {
  return state.posts.allIds.map(postId => {
    const post = state.posts.byId[postId];
    const comments = post.commentIds.map(
      commentId => state.comments.byId[commentId]
    );
    return { ...post, comments };
  });
};

// Component usage
const PostList = () => {
  const [state, dispatch] = useReducer(postsReducer, initialState);
  
  const posts = state.posts.allIds.map(id => state.posts.byId[id]);
  
  return (
    <div>
      {posts.map(post => (
        <Post
          key={post.id}
          post={post}
          author={state.users.byId[post.authorId]}
          onUpdate={(updates) => dispatch({
            type: 'UPDATE_POST',
            payload: { id: post.id, updates }
          })}
        />
      ))}
    </div>
  );
};

// Benefits of normalization:
// ✓ Single source of truth
// ✓ Fast lookups by ID
// ✓ Easy updates (no searching)
// ✓ No data duplication
// ✓ Better performance
Note: Consider normalization when you have: (1) nested entities, (2) the same entity in multiple places, (3) frequent updates to entities, (4) need for fast lookups. Libraries like normalizr can help.

6. State Machines and useReducer Patterns

Concept Description Benefit Example
State Machine Finite set of states and transitions Predictable, testable state logic Loading, success, error states
Valid Transitions Only allow specific state changes Prevent impossible states Can't be loading and success
Action-based Updates Dispatch actions to trigger transitions Clear intent, easier debugging FETCH, SUCCESS, ERROR actions
State Context Additional data within each state Rich state information Error message in error state
XState Integration Formal state machine library Visualization, advanced features Complex workflows

Example: State machine patterns with useReducer (Part 1/2)

// State machine for data fetching
const STATES = {
  IDLE: 'idle',
  LOADING: 'loading',
  SUCCESS: 'success',
  ERROR: 'error'
};

const fetchReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH':
      return { status: STATES.LOADING, data: null, error: null };
      
    case 'SUCCESS':
      return { status: STATES.SUCCESS, data: action.payload, error: null };
      
    case 'ERROR':
      return { status: STATES.ERROR, data: null, error: action.payload };
      
    case 'RESET':
      return { status: STATES.IDLE, data: null, error: null };
      
    default:
      return state;
  }
};

const DataFetcher = ({ url }) => {
  const [state, dispatch] = useReducer(fetchReducer, {
    status: STATES.IDLE,
    data: null,
    error: null
  });
  
  const fetchData = async () => {
    dispatch({ type: 'FETCH' });
    try {
      const response = await fetch(url);
      const data = await response.json();
      dispatch({ type: 'SUCCESS', payload: data });
    } catch (error) {
      dispatch({ type: 'ERROR', payload: error.message });
    }
  };
  
  // Render based on state
  if (state.status === STATES.IDLE) {
    return <button onClick={fetchData}>Load Data</button>;
  }
  
  if (state.status === STATES.LOADING) {
    return <div>Loading...</div>;
  }
  
  if (state.status === STATES.ERROR) {
    return (
      <div>
        Error: {state.error}
        <button onClick={fetchData}>Retry</button>
      </div>
    );
  }
  
  return (
    <div>
      <pre>{JSON.stringify(state.data, null, 2)}</pre>
      <button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
    </div>
  );
};

Example: State machine patterns with useReducer (Part 2/2)

// Complex state machine: Form wizard
const WIZARD_STATES = {
  PERSONAL_INFO: 'personalInfo',
  ADDRESS: 'address',
  PAYMENT: 'payment',
  REVIEW: 'review',
  SUBMITTING: 'submitting',
  SUCCESS: 'success',
  ERROR: 'error'
};

const wizardReducer = (state, action) => {
  switch (action.type) {
    case 'NEXT':
      const nextStates = {
        [WIZARD_STATES.PERSONAL_INFO]: WIZARD_STATES.ADDRESS,
        [WIZARD_STATES.ADDRESS]: WIZARD_STATES.PAYMENT,
        [WIZARD_STATES.PAYMENT]: WIZARD_STATES.REVIEW
      };
      return { ...state, currentStep: nextStates[state.currentStep] };
      
    case 'BACK':
      const prevStates = {
        [WIZARD_STATES.ADDRESS]: WIZARD_STATES.PERSONAL_INFO,
        [WIZARD_STATES.PAYMENT]: WIZARD_STATES.ADDRESS,
        [WIZARD_STATES.REVIEW]: WIZARD_STATES.PAYMENT
      };
      return { ...state, currentStep: prevStates[state.currentStep] };
      
    case 'UPDATE_DATA':
      return {
        ...state,
        formData: { ...state.formData, ...action.payload }
      };
      
    case 'SUBMIT':
      return { ...state, currentStep: WIZARD_STATES.SUBMITTING };
      
    case 'SUBMIT_SUCCESS':
      return { ...state, currentStep: WIZARD_STATES.SUCCESS };
      
    case 'SUBMIT_ERROR':
      return { 
        ...state, 
        currentStep: WIZARD_STATES.ERROR,
        error: action.payload
      };
      
    default:
      return state;
  }
};

// Benefits of state machines:
// ✓ Impossible states become impossible
// ✓ Clear transitions between states
// ✓ Easy to test each state
// ✓ Visual representation possible
// ✓ Predictable behavior

State Management Decision Guide

  • Local component state: Use useState for simple values, useReducer for complex logic
  • Sharing state: Lift to common parent, use composition to avoid prop drilling
  • State location: Keep state close to where it's used (colocation)
  • Derived values: Calculate from existing state, don't duplicate
  • Complex data: Normalize structure with byId/allIds pattern
  • State machines: Use for well-defined state transitions and workflows