Skip to main content

๐ŸŽฏ State Management Patterns

You've learned how to use useState in individual components - awesome! But real applications have multiple components that need to share and communicate data. How do you decide where to put state? When should components share state? How do you pass data between components efficiently? These are the questions that separate beginners from confident React developers. In this lesson, we'll explore the essential patterns for organizing state in your applications. 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 components that need it.

In other words: Keep state as close as possible to where it's used, but high enough that all components that need it can access it.

The State Placement Decision Tree

graph TD
    A[Need to add state] --> B{Used by one component?}
    B -->|Yes| C[Keep it local in that component]
    B -->|No| D{Used by sibling components?}
    D -->|Yes| E[Lift to parent]
    D -->|No| F{Used by distant relatives?}
    F -->|Yes| G[Lift to 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. It's how sibling components share data.

๐Ÿ“– Definition

Lifting State Up: Moving state from a child component to a parent component so that multiple children can share and synchronize that state.

The Problem: Siblings Can't Talk Directly

Scenario: Temperature Converter

Imagine two input fields - one for Celsius, one for Fahrenheit. When you type in either, the other should update. But they're sibling components!

graph TD
    A[App] --> B[CelsiusInput]
    A --> C[FahrenheitInput]
    
    B -.Can't talk directly!.-> C
    
    style B fill:#f44336,color:#fff
    style C fill:#f44336,color:#fff

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 has separate state!

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 they stay synchronized through the parent!

How Lifting State Works

sequenceDiagram
    participant User
    participant Child1
    participant Parent
    participant Child2
    
    User->>Child1: Type "25" in Celsius

                

๐ŸŽฎ Interactive Demo: Lifting State in Action

Watch how sibling components communicate through their parent:

Both inputs stay synchronized through lifted state!

Child1->>Parent: onTemperatureChange("25") Parent->>Parent: Update state, calculate Fahrenheit Parent->>Child1: Re-render with temp="25" Parent->>Child2: Re-render with temp="77" Child2->>User: Display "77" Fahrenheit Note over Parent: Parent is "source of truth"

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

๐ŸŽจ 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!
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

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

๐ŸŽจ 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 ๐ŸŽฏ Only 1 component re-renders!
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

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

๐ŸŽฎ Interactive Demo: Derived State in Action

Add items to the cart and watch how totals are calculated automatically - never stored!

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

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

How you initialize state matters! Let's explore different initialization strategies and when to use each.

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 need to communicate! Let's explore the different ways components can share information.

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 (

                

๐ŸŽจ 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 <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..." /> ); };

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