Skip to main content

๐ŸŽฏ State Management Patterns

You've learned how to use useState inside a single component - awesome! But real applications are made of many components that need to share and coordinate data. Where should a piece of state live? When should components share it, and when should they keep it to themselves? How do you move data around without turning your app into a tangle of props? These questions are exactly what separates a beginner from a confident React developer. In this lesson we'll work through the essential patterns for organizing state in a modern React 19 app - all with function components and TypeScript. Let's level up your state management skills! ๐Ÿš€

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Understand when and how to lift state up to parent components
  • Recognize and solve prop drilling problems
  • Apply state colocation for better component organization
  • Identify and calculate derived state instead of storing it
  • Choose appropriate state initialization strategies
  • Design component hierarchies with proper state placement
  • Refactor components to improve state management
  • Build scalable React applications with clean state architecture

Estimated Time: 75-90 minutes

Project: Build a todo list where multiple components share and manage state

In This Lesson

๐Ÿ“ State Placement Principles

The most important question in state management is: "Where should I put this state?" Let's learn the principles that guide this decision.

๐Ÿ“– The Golden Rule

State should live at the lowest common ancestor of all the components that need it.

In other words: keep state as close as possible to where it's used, but high enough that every component that needs it can reach it. Put it too low and siblings can't share it; put it too high and you drown unrelated components in re-renders and prop plumbing.

The State Placement Decision Tree

graph TD A[Need to add state] --> B{"Used by only one component?"} B -->|Yes| C[Keep it local in that component] B -->|No| D{"Used by sibling components?"} D -->|Yes| E[Lift to their shared parent] D -->|No| F{"Used by distant relatives?"} F -->|Yes| G[Lift to the common ancestor] style C fill:#4CAF50,color:#fff style E fill:#2196F3,color:#fff style G fill:#FF9800,color:#fff

Three Key Questions

Before Adding State, Ask:

  1. Can this be calculated from existing state or props?
    โ†’ If yes, don't store it - derive it instead!
  2. Which components need to read or update this state?
    โ†’ State should live in their common parent
  3. Is this state truly shared, or just passed through?
    โ†’ If just passed through, consider restructuring

State Ownership Models

Pattern When to Use Example
Local State Only one component needs it Toggle button's open/closed state
Lifted State Siblings need to share data Filter and list both need search term
Global State Many distant components need it User authentication, theme

๐Ÿ’ก Mental Model: The State Tree

Think of your component tree as an actual tree:

  • Leaves (components at bottom): Local state that affects only them
  • Branches (parent components): State shared by their children
  • Trunk (top-level components): State needed throughout the app

Water (state) flows down from trunk to branches to leaves, but never sideways!

โฌ†๏ธ Lifting State Up

Lifting state up is one of the most fundamental patterns in React - so fundamental that the official React docs teach it before almost anything else about sharing data. It's the answer to a very common question: "I have two components that need the same data. Where do I put it?"

๐Ÿ“– Definition

Lifting State Up: moving state out of a child component and into a shared parent, so that several children can read and update the same value and stay in sync.

The Problem: Siblings Can't Talk Directly

Scenario: Temperature Converter

Imagine two input fields - one for Celsius, one for Fahrenheit. Type into either one and the other should update to match. The catch: they're sibling components, and in React data only flows downward from parent to child. Siblings have no direct line to each other.

graph TD A[App] --> B[CelsiusInput] A --> C[FahrenheitInput] B -.->|"no direct path"| C style B fill:#f44336,color:#fff style C fill:#f44336,color:#fff

Think of two kids in different rooms who can only talk to a parent, never to each other directly. If they need to coordinate, the message has to go up to the parent and back down. That is exactly what lifting state up sets up.

The Solution: Lift State to Parent

Before: State in Children (Doesn't Work)

// โŒ Problem: Each has its own independent state
const CelsiusInput: React.FC = () => {
    const [temperature, setTemperature] = useState('');
    
    return (
        <input 
            value={temperature}
            onChange={(e) => setTemperature(e.target.value)}
        />
    );
};

const FahrenheitInput: React.FC = () => {
    const [temperature, setTemperature] = useState('');
    
    return (
        <input 
            value={temperature}
            onChange={(e) => setTemperature(e.target.value)}
        />
    );
};

โŒ They can't synchronize - each input owns a separate, independent piece of state. Typing in one has no way to reach the other.

After: State in Parent (Works!)

// โœ… Solution: Lift state to parent
interface TemperatureInputProps {
    temperature: string;
    onTemperatureChange: (temp: string) => void;
    scale: 'c' | 'f';
}

const TemperatureInput: React.FC<TemperatureInputProps> = ({
    temperature,
    onTemperatureChange,
    scale
}) => {
    const scaleName = scale === 'c' ? 'Celsius' : 'Fahrenheit';
    
    return (
        <div>
            <label>{scaleName}:</label>
            <input 
                value={temperature}
                onChange={(e) => onTemperatureChange(e.target.value)}
            />
        </div>
    );
};

// Parent component manages the shared state
const TemperatureConverter: React.FC = () => {
    const [temperature, setTemperature] = useState('');
    const [scale, setScale] = useState<'c' | 'f'>('c');
    
    const handleCelsiusChange = (temp: string) => {
        setScale('c');
        setTemperature(temp);
    };
    
    const handleFahrenheitChange = (temp: string) => {
        setScale('f');
        setTemperature(temp);
    };
    
    // Convert temperatures
    const celsius = scale === 'f' 
        ? ((parseFloat(temperature) - 32) * 5/9).toFixed(1)
        : temperature;
    const fahrenheit = scale === 'c'
        ? ((parseFloat(temperature) * 9/5) + 32).toFixed(1)
        : temperature;
    
    return (
        <div>
            <TemperatureInput
                temperature={celsius}
                onTemperatureChange={handleCelsiusChange}
                scale="c"
            />
            <TemperatureInput
                temperature={fahrenheit}
                onTemperatureChange={handleFahrenheitChange}
                scale="f"
            />
        </div>
    );
};

โœ… Now both fields stay synchronized through the parent โ€” one value, two views.

How Lifting State Works

Follow a single keystroke through the tree. When the user types into the Celsius field, the child does not update itself โ€” it calls the onTemperatureChange callback it was handed. The parent updates the one value it owns, recomputes the other scale, and re-renders both children with fresh props. The data makes a round trip: up via a callback, back down via props. That round trip is the whole trick.

sequenceDiagram participant User participant CelsiusInput participant Parent as TemperatureConverter participant FahrenheitInput User->>CelsiusInput: Types 25 in the Celsius field CelsiusInput->>Parent: onTemperatureChange(25) Parent->>Parent: setTemperature(25), convert to Fahrenheit Parent->>CelsiusInput: Re-render with 25 Parent->>FahrenheitInput: Re-render with 77 FahrenheitInput->>User: Shows 77 automatically Note over Parent: The parent is the single source of truth

๐ŸŽฎ Try It: One Source of Truth, Two Synced Views

Pick a temperature below. Notice you never set Fahrenheit directly โ€” the parent owns a single Celsius value and derives Fahrenheit from it, so the two readouts can never disagree.

const [celsius, setCelsius] = useState(20); // the parent owns the truth

๐Ÿ‘† Choose a temperature to watch both children re-render in sync.

Key Steps to Lift State

The Lifting State Up Recipe

  1. Remove state from children - Delete their useState calls
  2. Add state to parent - Parent now owns the data
  3. Pass data down as props - Children receive the current value
  4. Pass callbacks down - Children can request updates
  5. Make children controlled - They no longer manage their own state

โœ… Benefits of Lifting State

  • Single source of truth - One place owns the data
  • Synchronized updates - All components see the same data
  • Easier debugging - State logic is in one place
  • Better testability - Test parent, children are simple

๐Ÿ•ณ๏ธ Prop Drilling Problem

As your component tree grows, you might find yourself passing props through many layers of components that don't actually use them. This is called "prop drilling" - and it's a code smell!

๐Ÿ“– Definition

Prop Drilling: Passing props through multiple component layers, where intermediate components don't use the props - they just pass them down to deeper children.

The Problem Illustrated

graph TD A[App - has user state] --> B[Header] A --> C[Main] C --> D[Sidebar] C --> E[Content] E --> F[Article] F --> G[Author - needs user] A -.props drilling.-> B A -.props drilling.-> C C -.props drilling.-> E E -.props drilling.-> F F -.user prop.-> G style A fill:#667eea,color:#fff style B fill:#f44336,color:#fff style C fill:#f44336,color:#fff style E fill:#f44336,color:#fff style F fill:#f44336,color:#fff style G fill:#4CAF50,color:#fff

Example: Prop Drilling Code

// โŒ Prop drilling - passing user through many layers
interface User {
    name: string;
    avatar: string;
}

const App: React.FC = () => {
    const [user, setUser] = useState<User>({ name: 'Alice', avatar: '๐Ÿ‘ค' });
    
    return <Main user={user} />;
};

// Main doesn't use user, just passes it down
const Main: React.FC<{ user: User }> = ({ user }) => {
    return <Content user={user} />;
};

// Content doesn't use user, just passes it down
const Content: React.FC<{ user: User }> = ({ user }) => {
    return <Article user={user} />;
};

// Article doesn't use user, just passes it down
const Article: React.FC<{ user: User }> = ({ user }) => {
    return <Author user={user} />;
};

// Finally! Author actually uses it
const Author: React.FC<{ user: User }> = ({ user }) => {
    return <div>{user.avatar} {user.name}</div>;
};

Why Prop Drilling is Bad

Problem Impact
Verbose code Every intermediate component needs prop definitions
Hard to refactor Adding/removing props affects many files
Tight coupling Intermediate components know about data they don't use
Poor reusability Components can't be reused without the drilled props
Confusing Hard to track where data comes from and goes to

๐ŸŽจ Visualizing Prop Drilling

Prop Drilling: User Data Through 5 Levels App (owns user state) Layout passes user โ†“ Main passes user โ†“ Content passes user โ†“ Article passes user โ†“ Author (uses user!) Finally needs the data โœ“ โš ๏ธ Problems โ€ข 4 components just pass data โ€ข All need user in their props โ€ข Hard to refactor โ€ข Tight coupling โ€ข Can't reuse components โ€ข Props interfaces bloated These don't even USE user! โœ… Solutions 1. Composition Pass Author as child 2. Context API Skip intermediate layers 3. Restructure Move Author closer to App Remove the middlemen!
Figure: The same user object is threaded through four components that never read it โ€” the hallmark of prop drilling. Composition or Context lets you remove the middlemen.

Solutions to Prop Drilling

1. Component Composition (Recommended)

Instead of passing props down, pass components as children:

// โœ… Better: Use composition
const App: React.FC = () => {
    const [user, setUser] = useState<User>({ name: 'Alice', avatar: '๐Ÿ‘ค' });
    
    return (
        <Main>
            <Content>
                <Article>
                    <Author user={user} />
                </Article>
            </Content>
        </Main>
    );
};

// Now these components don't need to know about user!
const Main: React.FC<{ children: React.ReactNode }> = ({ children }) => {
    return <main>{children}</main>;
};

const Content: React.FC<{ children: React.ReactNode }> = ({ children }) => {
    return <div className="content">{children}</div>;
};

2. Context API (For Truly Global Data)

We'll cover this in detail in Module 5, but here's a preview:

// Create context (we'll learn this later!)
const UserContext = React.createContext<User | null>(null);

// Provider at top
const App: React.FC = () => {
    const [user, setUser] = useState<User>({ name: 'Alice', avatar: '๐Ÿ‘ค' });
    
    return (
        <UserContext.Provider value={user}>
            <Main />
        </UserContext.Provider>
    );
};

// Consumer deep in tree - no prop drilling!
const Author: React.FC = () => {
    const user = useContext(UserContext);
    return <div>{user?.avatar} {user?.name}</div>;
};

โš ๏ธ When is Prop Drilling Okay?

Prop drilling isn't always bad! It's fine when:

  • Only 1-2 levels deep
  • The data flow is clear and obvious
  • Components are tightly related
  • Alternative solutions would be more complex

Don't prematurely optimize! If it's not causing problems, it's fine.

๐Ÿ“ State Colocation

While we've talked about lifting state up, sometimes the opposite is true - you should push state down! This is called state colocation.

๐Ÿ“– Definition

State Colocation: Keeping state as close as possible to where it's used. If only one component (or a small component subtree) needs the state, keep it there instead of unnecessarily lifting it up.

The Colocation Principle

Kent C. Dodds' Rule

"Place code as close to where it's relevant as possible"

Don't lift state higher than necessary. Each component should own the minimal amount of state it needs.

Problem: State Too High in the Tree

Example: Accordion Component

// โŒ Bad: App manages accordion state it doesn't need
const App: React.FC = () => {
    const [openPanel, setOpenPanel] = useState<number | null>(null);
    
    return (
        <div>
            <Header />
            <Sidebar />
            <Accordion openPanel={openPanel} setOpenPanel={setOpenPanel} />
            <Footer />
        </div>
    );
};

// Why does App need to know about accordion state?
// Header, Sidebar, and Footer don't care!

Better: State Lives Where It's Used

// โœ… Good: Accordion manages its own state
const App: React.FC = () => {
    return (
        <div>
            <Header />
            <Sidebar />
            <Accordion />  {/* Self-contained! */}
            <Footer />
        </div>
    );
};

const Accordion: React.FC = () => {
    // State lives here - only this component needs it
    const [openPanel, setOpenPanel] = useState<number | null>(null);
    
    return (
        <div>
            <AccordionPanel
                id={0}
                isOpen={openPanel === 0}
                onToggle={() => setOpenPanel(openPanel === 0 ? null : 0)}
            >
                Panel 1 Content
            </AccordionPanel>
            <AccordionPanel
                id={1}
                isOpen={openPanel === 1}
                onToggle={() => setOpenPanel(openPanel === 1 ? null : 1)}
            >
                Panel 2 Content
            </AccordionPanel>
        </div>
    );
};

Benefits of State Colocation

Benefit Why It Matters
Better Performance Only relevant components re-render when state changes
Easier to Understand State logic is near where it's used
More Maintainable Changes are localized, not spread across files
Better Reusability Components are self-contained and portable
Cleaner Parent Components Parents don't manage irrelevant state

๐ŸŽจ State Colocation: Before vs After

State Colocation: Keep State Close to Where It's Used โŒ State Too High App [openPanel, setOpenPanel] Why does App care? ๐Ÿค” Header Sidebar Accordion Footer Problems: โ€ข App re-renders when panel changes โ€ข All siblings re-render too โ€ข Props drilling to Accordion โ€ข App bloated with unrelated state ๐Ÿ”„ 5 components re-render on panel change! โœ… State Colocated App No panel state here! Header Sidebar Accordion [openPanel] โœ“ Footer Benefits: โ€ข Only Accordion manages its state โ€ข Other components unaffected โ€ข No props drilling needed โ€ข Accordion is self-contained ๐ŸŽฏ Only 1 component re-renders!
Figure: Parking state too high in App re-renders unrelated siblings on every change; colocating it inside Accordion keeps both the re-renders and the prop plumbing local.

Real-World Example: Modal Dialog

Modal That Manages Its Own State

// โœ… Modal is self-contained
interface ModalProps {
    trigger: React.ReactNode;
    children: React.ReactNode;
}

const Modal: React.FC<ModalProps> = ({ trigger, children }) => {
    // State is colocated with the component that uses it
    const [isOpen, setIsOpen] = useState(false);
    
    return (
        <>
            <div onClick={() => setIsOpen(true)}>
                {trigger}
            </div>
            
            {isOpen && (
                <div className="modal-overlay" onClick={() => setIsOpen(false)}>
                    <div className="modal-content" onClick={(e) => e.stopPropagation()}>
                        {children}
                        <button onClick={() => setIsOpen(false)}>Close</button>
                    </div>
                </div>
            )}
        </>
    );
};

// Easy to use anywhere!
const App: React.FC = () => {
    return (
        <div>
            <Modal trigger={<button>Open Settings</button>}>
                <SettingsForm />
            </Modal>
            
            <Modal trigger={<button>Open Profile</button>}>
                <ProfileEditor />
            </Modal>
        </div>
    );
};

โœ… When to Colocate State

  • UI state (open/closed, hover, focus)
  • Form input values (if form is self-contained)
  • Component-specific filters or settings
  • Animation or transition state
  • Temporary or transient data

โš ๏ธ When NOT to Colocate

  • Multiple components need to read the state
  • Multiple components need to update the state
  • State needs to persist across unmounting
  • State affects URL or needs to be bookmarkable

๐Ÿ”„ Derived State

One of the most common mistakes in React is storing data that can be calculated from existing state or props. This creates unnecessary complexity and potential bugs!

๐Ÿ“– Definition

Derived State: Data that can be calculated from existing state or props. Instead of storing it in state, compute it during render.

The Problem: Redundant State

Example: Shopping Cart

// โŒ Bad: Storing derived values
interface CartItem {
    id: number;
    name: string;
    price: number;
    quantity: number;
}

const ShoppingCart: React.FC = () => {
    const [items, setItems] = useState<CartItem[]>([]);
    const [itemCount, setItemCount] = useState(0);      // Redundant!
    const [totalPrice, setTotalPrice] = useState(0);    // Redundant!
    
    const addItem = (item: CartItem) => {
        setItems([...items, item]);
        setItemCount(itemCount + 1);                     // Extra work!
        setTotalPrice(totalPrice + item.price);          // Extra work!
    };
    
    // What if we remove an item? Update all three states!
    // What if quantity changes? Update all three states!
    // Easy to get out of sync! ๐Ÿ˜ฑ
};

Better: Calculate Derived Values

// โœ… Good: Derive values from source of truth
interface CartItem {
    id: number;
    name: string;
    price: number;
    quantity: number;
}

const ShoppingCart: React.FC = () => {
    // Only store the source of truth
    const [items, setItems] = useState<CartItem[]>([]);
    
    // Derive everything else
    const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
    const totalPrice = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
    
    const addItem = (item: CartItem) => {
        setItems([...items, item]);
        // That's it! Counts update automatically
    };
    
    return (
        <div>
            <p>Items: {itemCount}</p>
            <p>Total: ${totalPrice.toFixed(2)}</p>
            {/* ... */}
        </div>
    );
};

Common Derived State Patterns

What You Want Don't Store Derive Instead
Full name const [fullName, ...] const fullName = `${first} ${last}`
Array length const [count, ...] const count = items.length
Filtered list const [filtered, ...] const filtered = items.filter(...)
Sorted list const [sorted, ...] const sorted = [...items].sort(...)
Computed total const [total, ...] const total = items.reduce(...)
Boolean check const [isEmpty, ...] const isEmpty = items.length === 0

๐ŸŽฎ Try It: Values You Never Store

Add items to the cart. The count, the total, and the number of unique products are recomputed on every click from the single array we store โ€” so they can never drift out of sync with it.

const [items, setItems] = useState<CartItem[]>([]); // the only stored state

๐Ÿ‘† Add items and watch the derived values keep themselves in sync.

Real Example: Todo List with Filters

Deriving Filtered Lists

interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

type Filter = 'all' | 'active' | 'completed';

const TodoList: React.FC = () => {
    // Store only the essential state
    const [todos, setTodos] = useState<Todo[]>([]);
    const [filter, setFilter] = useState<Filter>('all');
    
    // Derive filtered list (recalculated every render)
    const filteredTodos = todos.filter(todo => {
        if (filter === 'active') return !todo.completed;
        if (filter === 'completed') return todo.completed;
        return true; // 'all'
    });
    
    // Derive statistics
    const totalCount = todos.length;
    const activeCount = todos.filter(t => !t.completed).length;
    const completedCount = todos.filter(t => t.completed).length;
    
    return (
        <div>
            <div>
                <button onClick={() => setFilter('all')}>
                    All ({totalCount})
                </button>
                <button onClick={() => setFilter('active')}>
                    Active ({activeCount})
                </button>
                <button onClick={() => setFilter('completed')}>
                    Completed ({completedCount})
                </button>
            </div>
            
            <ul>
                {filteredTodos.map(todo => (
                    <li key={todo.id}>{todo.text}</li>
                ))}
            </ul>
        </div>
    );
};

๐Ÿ’ก Performance Note

You might worry: "Doesn't recalculating every render hurt performance?"

Usually, no! JavaScript is fast at simple operations. Only optimize if you have actual performance problems. For expensive calculations, you can use useMemo (we'll learn that in Module 5).

โœ… Benefits of Derived State

  • Single source of truth - No risk of state getting out of sync
  • Less code - No need to update multiple states
  • Fewer bugs - Can't forget to update derived values
  • Easier to reason about - Clear what the source data is

๐ŸŽฌ State Initialization

The value you hand to useState is the seed your component grows from โ€” and how you plant that seed matters. Pass a value directly and it's computed on every render (even though React only uses the first one). Pass a function and React runs it lazily, exactly once. Getting this right keeps expensive setup cheap and prevents a whole class of "why did my state reset?" surprises. Let's walk through each strategy and when to reach for it.

Basic Initialization

Direct Value

// Simple - pass the initial value directly
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [items, setItems] = useState<string[]>([]);

Lazy Initialization

Function Initializer

If calculating the initial state is expensive, pass a function instead:

// โŒ Bad: Runs expensive function on every render
const [todos, setTodos] = useState(loadTodosFromLocalStorage());
// loadTodosFromLocalStorage() runs every time, but only first value is used!

// โœ… Good: Lazy initialization - runs only once
const [todos, setTodos] = useState(() => loadTodosFromLocalStorage());
// Function only called on first render

// Another example
const [bigArray, setBigArray] = useState(() => {
    console.log('Computing initial state...');
    return Array.from({ length: 10000 }, (_, i) => i);
});
// Expensive computation only happens once!

Initialization from Props

Using Props for Initial State

// Common pattern: Initialize from props
interface CounterProps {
    initialCount: number;
}

const Counter: React.FC<CounterProps> = ({ initialCount }) => {
    const [count, setCount] = useState(initialCount);
    
    // Note: This only uses initialCount on first render
    // Later prop changes won't update state!
    
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(count + 1)}>+</button>
        </div>
    );
};

โš ๏ธ Important: Props Don't Re-Initialize State

// Parent changes initialCount
<Counter initialCount={10} />  // First render: count = 10
<Counter initialCount={20} />  // Re-render: count still 10!

// State is initialized once and then managed by the component
// Prop changes don't reset it!

Resetting State with Key

Using Key to Force Re-initialization

If you need state to reset when a prop changes, use the key prop:

// Force component to remount (and re-initialize state) when userId changes
<UserProfile key={userId} userId={userId} />

// Each unique key creates a new instance
<Counter key="counter1" initialCount={0} />
<Counter key="counter2" initialCount={10} />
// These are separate components with separate state!

Initialization Strategies

Strategy When to Use Example
Direct Value Simple, cheap computation useState(0)
Lazy Function Expensive computation useState(() => calculate())
From Props Initial value from parent useState(props.initial)
From Local Storage Persist across sessions useState(() => localStorage.get())
With Key Reset Reset when prop changes <Comp key={id} />

Local Storage Pattern

Persisting State Across Page Loads

const useLocalStorage = <T,>(key: string, initialValue: T) => {
    // Lazy initialization from localStorage
    const [value, setValue] = useState<T>(() => {
        try {
            const item = window.localStorage.getItem(key);
            return item ? JSON.parse(item) : initialValue;
        } catch (error) {
            console.error(error);
            return initialValue;
        }
    });
    
    // Update localStorage when state changes
    const setStoredValue = (newValue: T) => {
        try {
            setValue(newValue);
            window.localStorage.setItem(key, JSON.stringify(newValue));
        } catch (error) {
            console.error(error);
        }
    };
    
    return [value, setStoredValue] as const;
};

// Usage
const [name, setName] = useLocalStorage('userName', '');
// State persists across page refreshes!

๐Ÿ’ฌ Component Communication Patterns

Components rarely live in isolation โ€” they form a family, and families need to talk. React gives that conversation strict rules: data flows down through props and requests flow up through callbacks. It feels limiting at first, but this one-way street is exactly what makes React apps predictable: at any moment you can trace where a value came from and who's allowed to change it. Let's walk through the handful of patterns that cover almost every conversation your components will ever have.

Communication Flow Directions

graph TD A[Parent] -->|Props down| B[Child] B -->|Callbacks up| A C[Sibling 1] -.Can't talk directly.-> D[Sibling 2] E[Parent] -->|Props| C E -->|Props| D C -->|Callback| E E -->|Re-render| D style A fill:#667eea,color:#fff style E fill:#667eea,color:#fff

Pattern 1: Parent to Child (Props)

Passing Data Down

// Parent passes data to child via props
const Parent: React.FC = () => {
    const [message, setMessage] = useState('Hello!');
    
    return <Child message={message} />;
};

const Child: React.FC<{ message: string }> = ({ message }) => {
    return <p>{message}</p>;
};

Pattern 2: Child to Parent (Callbacks)

Passing Data Up

// Child calls parent's function to send data up
const Parent: React.FC = () => {
    const [value, setValue] = useState('');
    
    const handleChange = (newValue: string) => {
        setValue(newValue);
        console.log('Child sent:', newValue);
    };
    
    return (
        <div>
            <Child onValueChange={handleChange} />
            <p>Parent received: {value}</p>
        </div>
    );
};

const Child: React.FC<{ onValueChange: (value: string) => void }> = ({ onValueChange }) => {
    return (
        <input 
            onChange={(e) => onValueChange(e.target.value)}
            placeholder="Type something..."
        />
    );
};

๐ŸŽจ Data Flow Patterns Visualized

Component Communication: Unidirectional Data Flow Parent Component [state, setState] = useState(...) Source of truth Child A (Input) Receives: value prop Sends: onChange callback Child B (Display) Receives: value prop Read-only display Props โ†“ Props โ†“ Callback โ†‘ Re-render with new props Props Down Data flows from parent to children Read-only in children Callbacks Up Children request state changes Parent decides what to update Siblings via Parent No direct sibling communication Parent coordinates all updates
Figure: Data flows one way โ€” props down, callbacks up. Siblings never talk directly; the parent coordinates every update and re-renders the children that need it.

Pattern 3: Sibling to Sibling (via Parent)

Sharing Data Between Siblings

// Siblings communicate through shared parent state
const Parent: React.FC = () => {
    const [sharedValue, setSharedValue] = useState('');
    
    return (
        <div>
            <InputChild onValueChange={setSharedValue} />
            <DisplayChild value={sharedValue} />
        </div>
    );
};

const InputChild: React.FC<{ onValueChange: (v: string) => void }> = ({ onValueChange }) => {
    return <input onChange={(e) => onValueChange(e.target.value)} />;
};

const DisplayChild: React.FC<{ value: string }> = ({ value }) => {
    return <p>Sibling says: {value}</p>;
};

Pattern 4: Render Props

Sharing Logic with Render Props

// Component provides functionality, parent controls rendering
interface MousePosition {
    x: number;
    y: number;
}

interface MouseTrackerProps {
    children: (position: MousePosition) => React.ReactNode;
}

const MouseTracker: React.FC<MouseTrackerProps> = ({ children }) => {
    const [position, setPosition] = useState({ x: 0, y: 0 });
    
    const handleMouseMove = (e: React.MouseEvent) => {
        setPosition({ x: e.clientX, y: e.clientY });
    };
    
    return (
        <div onMouseMove={handleMouseMove} style={{ height: '100vh' }}>
            {children(position)}
        </div>
    );
};

// Usage - consumer controls what to render
<MouseTracker>
    {({ x, y }) => (
        <p>Mouse position: {x}, {y}</p>
    )}
</MouseTracker>

โœ… Communication Best Practices

  • Prefer props and callbacks - Simple and explicit
  • Keep data flow unidirectional - Down via props, up via callbacks
  • Name callbacks clearly - Use "on" prefix: onValueChange, onSubmit
  • Lift state only when necessary - Don't prematurely optimize
  • Consider composition - Sometimes better than prop drilling

๐Ÿ‹๏ธ Hands-on Practice

Time to apply these patterns! Let's build components that demonstrate proper state management.

๐Ÿ‹๏ธ Exercise 1: Filter and List

Goal: Build a searchable list where search input and list are siblings.

Requirements:

  • Parent component manages the search term
  • SearchInput component (controlled input)
  • FilteredList component (displays filtered items)
  • Search term passed down, updates passed up
  • Items list should be hardcoded in parent
๐Ÿ’ก Hint

Structure your components like this:

const App = () => {
    const [searchTerm, setSearchTerm] = useState('');
    const items = ['Apple', 'Banana', 'Cherry', ...];
    const filtered = items.filter(item => 
        item.toLowerCase().includes(searchTerm.toLowerCase())
    );
    
    return (
        <div>
            <SearchInput value={searchTerm} onChange={setSearchTerm} />
            <FilteredList items={filtered} />
        </div>
    );
};
โœ… Solution
import React, { useState } from 'react';

interface SearchInputProps {
    value: string;
    onChange: (value: string) => void;
}

const SearchInput: React.FC<SearchInputProps> = ({ value, onChange }) => {
    return (
        <input
            type="text"
            value={value}
            onChange={(e) => onChange(e.target.value)}
            placeholder="Search..."
            style={{ padding: '0.5rem', width: '100%', marginBottom: '1rem' }}
        />
    );
};

interface FilteredListProps {
    items: string[];
}

const FilteredList: React.FC<FilteredListProps> = ({ items }) => {
    return (
        <ul style={{ listStyle: 'none', padding: 0 }}>
            {items.length === 0 ? (
                <li style={{ color: '#999' }}>No items found</li>
            ) : (
                items.map((item, index) => (
                    <li key={index} style={{ padding: '0.5rem', borderBottom: '1px solid #eee' }}>
                        {item}
                    </li>
                ))
            )}
        </ul>
    );
};

const SearchableList: React.FC = () => {
    const [searchTerm, setSearchTerm] = useState('');
    
    const items = [
        'Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry',
        'Cranberry', 'Date', 'Fig', 'Grape', 'Kiwi',
        'Lemon', 'Lime', 'Mango', 'Orange', 'Papaya',
        'Peach', 'Pear', 'Pineapple', 'Plum', 'Strawberry'
    ];
    
    // Derive filtered list - don't store it!
    const filteredItems = items.filter(item =>
        item.toLowerCase().includes(searchTerm.toLowerCase())
    );
    
    return (
        <div style={{ maxWidth: '400px', margin: '0 auto', padding: '2rem' }}>
            <h2>Fruit Search</h2>
            <SearchInput value={searchTerm} onChange={setSearchTerm} />
            <p>Showing {filteredItems.length} of {items.length} items</p>
            <FilteredList items={filteredItems} />
        </div>
    );
};

export default SearchableList;

๐Ÿ‹๏ธ Exercise 2: Tabs Component

Goal: Build a tabs component that manages its own active tab state.

Requirements:

  • Tabs component manages active tab internally (state colocation)
  • Tab buttons to switch between tabs
  • Content area that shows active tab content
  • Highlight active tab
  • Parent shouldn't need to manage tab state
๐Ÿ’ก Hint
interface Tab {
    id: string;
    label: string;
    content: React.ReactNode;
}

const Tabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => {
    const [activeTab, setActiveTab] = useState(tabs[0].id);
    // Render tabs and content...
};
โœ… Solution
import React, { useState } from 'react';

interface Tab {
    id: string;
    label: string;
    content: React.ReactNode;
}

interface TabsProps {
    tabs: Tab[];
}

const Tabs: React.FC<TabsProps> = ({ tabs }) => {
    // State is colocated - only Tabs needs to know which tab is active
    const [activeTab, setActiveTab] = useState(tabs[0]?.id || '');
    
    const activeContent = tabs.find(tab => tab.id === activeTab)?.content;
    
    return (
        <div style={{ border: '1px solid #ddd', borderRadius: '8px', overflow: 'hidden' }}>
            {/* Tab buttons */}
            <div style={{ display: 'flex', borderBottom: '2px solid #ddd' }}>
                {tabs.map(tab => (
                    <button
                        key={tab.id}
                        onClick={() => setActiveTab(tab.id)}
                        style={{
                            flex: 1,
                            padding: '1rem',
                            border: 'none',
                            background: activeTab === tab.id ? '#667eea' : '#f5f5f5',
                            color: activeTab === tab.id ? 'white' : '#333',
                            cursor: 'pointer',
                            fontWeight: activeTab === tab.id ? 'bold' : 'normal',
                            transition: 'all 0.2s'
                        }}
                    >
                        {tab.label}
                    </button>
                ))}
            </div>
            
            {/* Tab content */}
            <div style={{ padding: '2rem' }}>
                {activeContent}
            </div>
        </div>
    );
};

// Usage - parent doesn't need to manage tab state!
const App: React.FC = () => {
    const tabs = [
        {
            id: 'profile',
            label: 'Profile',
            content: <div>Profile information goes here</div>
        },
        {
            id: 'settings',
            label: 'Settings',
            content: <div>Settings options go here</div>
        },
        {
            id: 'notifications',
            label: 'Notifications',
            content: <div>Notification preferences go here</div>
        }
    ];
    
    return (
        <div style={{ maxWidth: '600px', margin: '2rem auto' }}>
            <h1>User Dashboard</h1>
            <Tabs tabs={tabs} />
        </div>
    );
};

export default App;

๐Ÿ‹๏ธ Exercise 3: Parent-Child Sync

Goal: Build a color picker where RGB inputs sync with a color preview.

Requirements:

  • Three sliders for Red, Green, Blue (0-255)
  • Color preview box showing current color
  • Display hex code of current color
  • Parent manages RGB state, children are controlled
  • Derive hex color - don't store it!
๐Ÿ’ก Hint
const [rgb, setRgb] = useState({ r: 0, g: 0, b: 0 });

const hexColor = `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
โœ… Solution
import React, { useState } from 'react';

interface RGB {
    r: number;
    g: number;
    b: number;
}

interface ColorSliderProps {
    label: string;
    value: number;
    onChange: (value: number) => void;
    color: string;
}

const ColorSlider: React.FC<ColorSliderProps> = ({ label, value, onChange, color }) => {
    return (
        <div style={{ marginBottom: '1rem' }}>
            <label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
                {label}: {value}
            </label>
            <input
                type="range"
                min="0"
                max="255"
                value={value}
                onChange={(e) => onChange(parseInt(e.target.value))}
                style={{ width: '100%', accentColor: color }}
            />
        </div>
    );
};

const ColorPicker: React.FC = () => {
    const [rgb, setRgb] = useState<RGB>({ r: 100, g: 150, b: 200 });
    
    // Derive hex color - don't store it!
    const toHex = (n: number) => n.toString(16).padStart(2, '0');
    const hexColor = `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
    
    return (
        <div style={{ maxWidth: '400px', margin: '2rem auto', padding: '2rem' }}>
            <h2>RGB Color Picker</h2>
            
            {/* Color Preview */}
            <div
                style={{
                    width: '100%',
                    height: '150px',
                    backgroundColor: hexColor,
                    borderRadius: '8px',
                    marginBottom: '1.5rem',
                    border: '2px solid #ddd',
                    display: 'flex',
                    alignItems: 'center',
                    justifyContent: 'center',
                    color: rgb.r + rgb.g + rgb.b > 382 ? '#000' : '#fff',
                    fontSize: '1.5rem',
                    fontWeight: 'bold'
                }}
            >
                {hexColor.toUpperCase()}
            </div>
            
            {/* Sliders */}
            <ColorSlider
                label="Red"
                value={rgb.r}
                onChange={(r) => setRgb({ ...rgb, r })}
                color="#ff0000"
            />
            
            <ColorSlider
                label="Green"
                value={rgb.g}
                onChange={(g) => setRgb({ ...rgb, g })}
                color="#00ff00"
            />
            
            <ColorSlider
                label="Blue"
                value={rgb.b}
                onChange={(b) => setRgb({ ...rgb, b })}
                color="#0000ff"
            />
            
            {/* RGB Values */}
            <div style={{ marginTop: '1.5rem', padding: '1rem', background: '#f5f5f5', borderRadius: '4px' }}>
                <p style={{ margin: '0.25rem 0' }}><strong>RGB:</strong> ({rgb.r}, {rgb.g}, {rgb.b})</p>
                <p style={{ margin: '0.25rem 0' }}><strong>Hex:</strong> {hexColor.toUpperCase()}</p>
            </div>
        </div>
    );
};

export default ColorPicker;

๐Ÿ”ง Refactoring Exercise

Let's take poorly organized state and refactor it to follow best practices!

โŒ Bad Code: Multiple Issues

This code has several state management problems. Can you spot them?

// ๐Ÿšจ PROBLEMS IN THIS CODE!
const BadTodoApp: React.FC = () => {
    const [todos, setTodos] = useState<Todo[]>([]);
    const [completedCount, setCompletedCount] = useState(0);  // Problem 1
    const [activeCount, setActiveCount] = useState(0);        // Problem 1
    const [filteredTodos, setFilteredTodos] = useState<Todo[]>([]);  // Problem 1
    const [filter, setFilter] = useState('all');
    
    const addTodo = (text: string) => {
        const newTodo = { id: Date.now(), text, completed: false };
        setTodos([...todos, newTodo]);
        setActiveCount(activeCount + 1);  // Problem 2: Easy to forget
        setFilteredTodos([...filteredTodos, newTodo]);  // Problem 2
    };
    
    const toggleTodo = (id: number) => {
        const updated = todos.map(t => 
            t.id === id ? { ...t, completed: !t.completed } : t
        );
        setTodos(updated);
        // Problem 3: Forgot to update counts! ๐Ÿ˜ฑ
    };
    
    return <div>{/* JSX */}</div>;
};

Problems:

  1. Storing derived state (counts and filtered list can be calculated)
  2. Have to remember to update multiple states
  3. Easy to forget updates, causing bugs (toggle forgot to update counts!)

โœ… Good Code: Refactored

// โœจ FIXED VERSION
interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

type Filter = 'all' | 'active' | 'completed';

const GoodTodoApp: React.FC = () => {
    // Store only essential state
    const [todos, setTodos] = useState<Todo[]>([]);
    const [filter, setFilter] = useState<Filter>('all');
    
    // Derive everything else
    const completedCount = todos.filter(t => t.completed).length;
    const activeCount = todos.filter(t => !t.completed).length;
    const filteredTodos = todos.filter(t => {
        if (filter === 'active') return !t.completed;
        if (filter === 'completed') return t.completed;
        return true;
    });
    
    const addTodo = (text: string) => {
        if (!text.trim()) return;
        const newTodo: Todo = {
            id: Date.now(),
            text: text.trim(),
            completed: false
        };
        setTodos([...todos, newTodo]);
        // That's it! Counts update automatically โœจ
    };
    
    const toggleTodo = (id: number) => {
        setTodos(todos.map(t =>
            t.id === id ? { ...t, completed: !t.completed } : t
        ));
        // Counts update automatically here too! โœจ
    };
    
    const deleteTodo = (id: number) => {
        setTodos(todos.filter(t => t.id !== id));
    };
    
    return (
        <div style={{ maxWidth: '600px', margin: '2rem auto', padding: '2rem' }}>
            <h1>Todo List</h1>
            
            {/* Add Todo Form */}
            <TodoInput onAddTodo={addTodo} />
            
            {/* Filter Buttons */}
            <div style={{ margin: '1rem 0', display: 'flex', gap: '0.5rem' }}>
                <button onClick={() => setFilter('all')}>
                    All ({todos.length})
                </button>
                <button onClick={() => setFilter('active')}>
                    Active ({activeCount})
                </button>
                <button onClick={() => setFilter('completed')}>
                    Completed ({completedCount})
                </button>
            </div>
            
            {/* Todo List */}
            <TodoList
                todos={filteredTodos}
                onToggle={toggleTodo}
                onDelete={deleteTodo}
            />
        </div>
    );
};

// Separate components for better organization
interface TodoInputProps {
    onAddTodo: (text: string) => void;
}

const TodoInput: React.FC<TodoInputProps> = ({ onAddTodo }) => {
    const [text, setText] = useState('');
    
    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        onAddTodo(text);
        setText('');
    };
    
    return (
        <form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
            <input
                value={text}
                onChange={(e) => setText(e.target.value)}
                placeholder="Add a todo..."
                style={{ flex: 1, padding: '0.5rem' }}
            />
            <button type="submit" style={{ padding: '0.5rem 1rem' }}>Add</button>
        </form>
    );
};

interface TodoListProps {
    todos: Todo[];
    onToggle: (id: number) => void;
    onDelete: (id: number) => void;
}

const TodoList: React.FC<TodoListProps> = ({ todos, onToggle, onDelete }) => {
    if (todos.length === 0) {
        return <p style={{ textAlign: 'center', color: '#999' }}>No todos yet!</p>;
    }
    
    return (
        <ul style={{ listStyle: 'none', padding: 0 }}>
            {todos.map(todo => (
                <li
                    key={todo.id}
                    style={{
                        display: 'flex',
                        alignItems: 'center',
                        padding: '0.75rem',
                        marginBottom: '0.5rem',
                        background: '#f5f5f5',
                        borderRadius: '4px'
                    }}
                >
                    <input
                        type="checkbox"
                        checked={todo.completed}
                        onChange={() => onToggle(todo.id)}
                        style={{ marginRight: '0.75rem' }}
                    />
                    <span
                        style={{
                            flex: 1,
                            textDecoration: todo.completed ? 'line-through' : 'none',
                            color: todo.completed ? '#999' : '#333'
                        }}
                    >
                        {todo.text}
                    </span>
                    <button
                        onClick={() => onDelete(todo.id)}
                        style={{
                            background: '#f44336',
                            color: 'white',
                            border: 'none',
                            padding: '0.25rem 0.5rem',
                            borderRadius: '4px',
                            cursor: 'pointer'
                        }}
                    >
                        Delete
                    </button>
                </li>
            ))}
        </ul>
    );
};

Improvements:

  • โœ… Only stores essential state (todos, filter)
  • โœ… Derives counts and filtered list
  • โœ… No risk of state getting out of sync
  • โœ… Cleaner update functions
  • โœ… Components properly separated
  • โœ… State lifted to appropriate level

โœจ Best Practices

Let's consolidate everything you've learned into actionable best practices.

โœ… 1. Single Source of Truth

Each piece of data should have exactly one source of truth:

  • Don't duplicate state between components
  • Don't store derived values in state
  • Lift state to the lowest common ancestor

โœ… 2. Minimize State

Before adding state, ask: "Can I calculate this?"

// โŒ Don't store what you can calculate
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);

// โœ… Derive instead
const [items, setItems] = useState([]);
const count = items.length;

โœ… 3. Colocate State

Keep state as local as possible:

  • Only lift state when multiple components need it
  • UI state (hover, focus) usually stays local
  • Self-contained components are easier to maintain

โœ… 4. Use Composition Over Prop Drilling

When passing props through multiple layers, consider composition:

// โŒ Prop drilling
<Layout user={user}>
    <Sidebar user={user}>
        <UserMenu user={user} />
    </Sidebar>
</Layout>

// โœ… Composition
<Layout>
    <Sidebar>
        <UserMenu user={user} />
    </Sidebar>
</Layout>

โœ… 5. Name Callbacks Clearly

Use consistent naming for callbacks:

// โœ… Good naming
onValueChange, onSubmit, onUserSelect, onModalClose

// โŒ Unclear naming
change, submit, click, close

State Management Decision Tree

graph TD A[Need to add data] --> B{Can it be calculated?} B -->|Yes| C[Don't store it!
Derive it instead] B -->|No| D{How many components
need it?} D -->|One| E[Local state in
that component] D -->|Siblings| F[Lift to parent] D -->|Many/distant| G[Consider Context
or global state] style C fill:#4CAF50,color:#fff style E fill:#4CAF50,color:#fff style F fill:#2196F3,color:#fff style G fill:#FF9800,color:#fff

Checklist for Good State Management

Question Good Answer
Can this be calculated from other state/props? If yes, derive it - don't store it
Which components need this state? Put it in their lowest common ancestor
Is this state truly global? Most state isn't - keep it local when possible
Are we passing props through many layers? Consider composition or Context
Could this state live lower in the tree? Push it down for better performance
Is our state synchronized correctly? Single source of truth prevents sync issues

๐Ÿ“š Summary

Congratulations! You've mastered the essential patterns for managing state in React applications. These patterns are the foundation of building scalable React apps.

What You Learned

๐Ÿ“ State Placement

  • The golden rule: lowest common ancestor
  • Three key questions before adding state
  • Local vs lifted vs global state

โฌ†๏ธ Lifting State Up

  • How to share state between sibling components
  • Moving state to parent components
  • Props down, callbacks up pattern
  • Creating controlled components

๐Ÿ•ณ๏ธ Prop Drilling

  • Recognizing prop drilling problems
  • Solutions: composition and Context
  • When prop drilling is acceptable

๐Ÿ“ State Colocation

  • Keeping state close to where it's used
  • Benefits of localized state
  • Self-contained components

๐Ÿ”„ Derived State

  • Calculating values instead of storing them
  • Avoiding redundant state
  • Single source of truth

๐ŸŽฌ State Initialization

  • Direct vs lazy initialization
  • Initializing from props
  • Using key prop to reset state
  • Local storage pattern

๐ŸŽฏ Key Takeaways

  • Minimize state - Don't store what you can calculate
  • Single source of truth - Each piece of data has one owner
  • Lift judiciously - Only lift when necessary
  • Colocate when possible - Keep state local for better performance
  • Derive, don't duplicate - Calculate derived values on the fly
  • Use composition - Often better than prop drilling

๐Ÿš€ Next Steps

With these patterns mastered, you're ready for more advanced topics:

๐Ÿ“– Coming Up Next

Lesson 3.3: Forms in React

  • Controlled vs uncontrolled components
  • Handling multiple form inputs
  • Form validation patterns
  • Type-safe form handling

You'll use the state management patterns you just learned to build complex forms!

๐Ÿ’ช Practice Suggestions

To master these patterns, try building:

  • Dashboard with Widgets - Multiple components sharing filter state
  • Multi-page Form - Step navigation with state persisting across steps
  • Shopping Cart - Product list, cart, and checkout sharing state
  • Social Feed - Posts, comments, likes with proper state organization
  • Kanban Board - Drag and drop with columns sharing task state

โœจ Remember

Good state management is about making deliberate decisions. Before adding state, think: Where should it live? Can it be derived? Who needs access? These patterns will guide you to cleaner, more maintainable React applications!

๐ŸŽ‰ Congratulations!

You've completed Lesson 3.2 and learned professional state management patterns! You now know how to organize state in scalable React applications. These patterns will serve you throughout your React career. Excellent work! ๐Ÿš€

โ† Previous Lesson 3.1: useState Hook โŒ‚ Home Next โ†’ Lesson 3.3: Forms in React