跪拜 Guibai
← Back to the summary

React State Lifting, Explained with a Color Picker

You're building a color picker. A color block displays the current color, and three sliders adjust R, G, and B.

You've written a bunch of code stuffed into a single component. It works, but you want to split it—color display into ColorBrowser, slider controls into ColorPicker. You've written a bunch of code stuffed into a single component. It works, but you want to split it—color display into ColorBrowser, slider controls into ColorPicker.

The question is: where should the color state live?

Inside ColorPicker? Then ColorBrowser can't read it. Inside App? Then how does ColorPicker change it? Store a copy in each? How do they stay in sync?

This article solves that problem.


In a Nutshell: What is State Lifting

State lifting: When multiple components need to share the same piece of state, move that state to their closest common ancestor component, pass it down via props, and notify the ancestor of changes via callback functions.

The data flow resembles a tree:

graph TD
    App["App holds color state"]
    App -->|"props: color (read-only)"| Browser["ColorBrowser"]
    App -->|"props: color + onColorUpdated"| Picker["ColorPicker"]
    Picker -->|"onColorUpdated(newColor) callback notifies upward"| App

It's not "child components passing data to each other," but rather lifting shared data to their common parent component, making it the single source of truth.


From Code: From a Monolith to Split Components

📦 Final Project Structure

src/
├── App.tsx                      ← State "owner"
├── model/color.ts               ← Color type definition
└── components/
    ├── ColorBrowser.tsx          ← Pure display: receives color, shows a color block
    └── ColorPicker.tsx           ← Control panel: sliders + notifies parent of changes

Step 1: Define the Shape of the Color Data

// src/model/color.ts
export interface Color {
  red: number;
  green: number;
  blue: number;
}

Nothing special, just an RGB triplet. The benefit of a separate interface definition: App, ColorBrowser, and ColorPicker all use the same type; change the type in one place.

Step 2: App — The True Owner of State

// src/App.tsx
import { useState } from 'react'
import ColorBrowser from './components/ColorBrowser'
import { type Color } from './model/color'
import ColorPicker from './components/ColorPicker'

function App() {
  // 🔑 Key: color state lives in App, the closest common ancestor of ColorBrowser and ColorPicker
  const [color, setColor] = useState<Color>({
    red: 20,
    green: 240,
    blue: 180
  })

  return (
    <>
      <ColorBrowser color={color} />
      <ColorPicker color={color} onColorUpdated={setColor} />
    </>
  )
}

export default App

🔑 This line const [color, setColor] = useState<Color>({...}) is the single source of truth for the entire state lifting pattern. Only one place in the whole application knows what the current color is: right here.

Note onColorUpdated={setColor}directly passing setColor down as a callback. ColorPicker doesn't need to know how or where color is stored; it just needs to call onColorUpdated(newColor) when the user drags a slider.

Step 3: ColorBrowser — Pure Display, Read-Only

// src/components/ColorBrowser.tsx
import type { Color } from '../model/color'

interface Props {
  color: Color;
}

const ColorBrowser: React.FC<Props> = (props) => {
  const divStyle: React.CSSProperties = {
    width: "11rem",
    height: "7rem",
    // 🔑 Reads color from props, holds no state of its own
    backgroundColor: `rgb(${props.color.red},${props.color.green},${props.color.blue})`
  }

  return <div style={divStyle} />
}

export default ColorBrowser

This component doesn't have a single line of useState. It displays whatever color it receives; it has no "own color." In React, this kind of component is called a "controlled component"—its behavior is entirely determined by the parent's props.

Step 4: ColorPicker — Control Panel, Read/Write Separation

// src/components/ColorPicker.tsx
import type { Color } from '../model/color'

interface Props {
  color: Color;
  onColorUpdated: (color: Color) => void; // ⚠️ Note: receives a new Color, returns nothing
}

const ColorPicker: React.FC<Props> = (props) => {
  return (
    <div>
      <input
        type="range"
        min={0}
        max={255}
        value={props.color.red}
        onChange={event => {
          // 🔑 Key: doesn't mutate props.color directly; constructs a new object for the callback
          // ⚠️ Gotcha: event.target.value is a string; use + to convert to number
          props.onColorUpdated({
            ...props.color,    // Preserve current green and blue values
            red: +event.target.value  // Override only the red channel
          })
        }}
      />
      {props.color.red}
      <br />
      {/* Green slider: same pattern */}
      <input
        type="range" min={0} max={255}
        value={props.color.green}
        onChange={event => {
          props.onColorUpdated({
            ...props.color,
            green: +event.target.value
          })
        }}
      />
      {props.color.green}
      <br />
      {/* Blue slider: same pattern */}
      <input
        type="range" min={0} max={255}
        value={props.color.blue}
        onChange={event => {
          props.onColorUpdated({
            ...props.color,
            blue: +event.target.value
          })
        }}
      />
      {props.color.blue}
    </div>
  )
}

export default ColorPicker

Three things here are worth unpacking.


Three Key Details

⚠️ Gotcha 1: event.target.value is a string, not a number

// ❌ Common mistake
red: event.target.value  // "128" — a string! rgb("128","0","0") won't display correctly

// ✅ Recommended approach
red: +event.target.value  // 128 — a number. The unary + is the most concise string→number conversion

Why this pitfall matters: CSS's rgb() function can actually tolerate string numbers, but TypeScript's type system won't—Color.red is typed as number, and passing a string will cause a build-time error. More critically, if subsequent math operations exist (like contrast ratio calculations), "128" * 2 = 256 might coincidentally work, but "128" + 1 = "1281"—string concatenation, not mathematical addition.

⚠️ Gotcha 2: Why must ...props.color be spread first?

// When changing the red slider:
props.onColorUpdated({
  ...props.color,       // ① First, spread the current complete color
  red: +event.target.value  // ② Then override the red channel
})

Without spreading, you'd only pass { red: 128 }—green and blue are lost! Color becomes { red: 128 }, and the color block disappears in the browser.

In other words: setState is not a "partial update"; it's a full replacement. This is completely different from class component's this.setState.

🔑 Design Decision: Why doesn't ColorPicker call setColor directly, but uses a callback instead?

If written like this:

// ❌ Anti-pattern: ColorPicker directly imports setColor from the parent
import { setColor } from '../App'

Then ColorPicker would be tightly coupled to App, making it impossible to reuse on a different page.

Benefits of the callback pattern:


Looking Back: What If State Isn't Lifted?

Suppose you put the color state inside ColorPicker:

❌ ColorBrowser can't read color → can't display the current color
❌ Or: ColorBrowser also stores its own copy of color → two independent states, dragging the slider does nothing to the color block
❌ Or: sync via ref / forceUpdate / event bus → code becomes increasingly unmaintainable

This is the true value of state lifting—not "making code look more advanced," but fundamentally eliminating the bug class of "two copies of data out of sync."


📊 Complete Data Flow Review

sequenceDiagram
    participant U as User
    participant CP as ColorPicker
    participant App as App
    participant CB as ColorBrowser

    U->>CP: Drags red slider to 200
    CP->>App: onColorUpdated({ red:200, green:240, blue:180 })
    App->>App: setColor() → triggers re-render
    App->>CP: props.color = { red:200, green:240, blue:180 }
    App->>CB: props.color = { red:200, green:240, blue:180 }
    CP->>U: Slider position updates + value 200 displayed
    CB->>U: Color block becomes rgb(200,240,180)

Six steps complete, and both components update synchronously. In the entire chain, only App "knows the truth"; ColorBrowser and ColorPicker merely "receive notifications."


A Checklist: Ask Yourself When Splitting Components

# Question If "Yes"
1 Does this state need to be read by multiple components? Lift to common ancestor
2 Does this state need to be modified by child components? Pass a callback function down
3 Can a child component directly modify state without the parent? No—this is the core constraint of controlled components
4 Is the same data stored in two places? Delete one, keep only the "single source of truth"

Remember the answers to these 4 questions, and you've mastered state lifting.


Extended Thinking

If you look back at the ColorPicker component's code, you'll notice the three sliders have nearly identical structures—all are "a range input + display value," just operating on different fields. How can this repetition be eliminated?

Let's discuss in the comments: How would you refactor ColorPicker to abstract the three sliders into a generic ColorSlider component? What are the benefits, and what new trade-offs would it introduce?


State lifting isn't a React rule, but the natural expression of the "single source of truth" software engineering principle within React. When you're torn about where state should live, ask yourself: Who should hold the "truth" of this data? There's always only one answer.