Lifting state enables component communication through a common ancestor. Here's how to do it effectively.
Basic State Lifting#
1// Problem: Two components need to share state
2function TemperatureInput({ scale, value, onChange }) {
3 return (
4 <div>
5 <label>Temperature in {scale}:</label>
6 <input
7 value={value}
8 onChange={(e) => onChange(e.target.value)}
9 />
10 </div>
11 );
12}
13
14// Solution: Lift state to common parent
15function TemperatureCalculator() {
16 const [celsius, setCelsius] = useState('');
17
18 const fahrenheit = celsius !== ''
19 ? (parseFloat(celsius) * 9) / 5 + 32
20 : '';
21
22 const handleCelsiusChange = (value: string) => {
23 setCelsius(value);
24 };
25
26 const handleFahrenheitChange = (value: string) => {
27 const c = value !== '' ? ((parseFloat(value) - 32) * 5) / 9 : '';
28 setCelsius(String(c));
29 };
30
31 return (
32 <div>
33 <TemperatureInput
34 scale="Celsius"
35 value={celsius}
36 onChange={handleCelsiusChange}
37 />
38 <TemperatureInput
39 scale="Fahrenheit"
40 value={String(fahrenheit)}
41 onChange={handleFahrenheitChange}
42 />
43 </div>
44 );
45}Sibling Communication#
1// Two siblings need to communicate
2interface Product {
3 id: string;
4 name: string;
5 price: number;
6}
7
8// Parent holds shared state
9function ProductPage() {
10 const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
11 const [products] = useState<Product[]>([
12 { id: '1', name: 'Widget', price: 10 },
13 { id: '2', name: 'Gadget', price: 20 },
14 ]);
15
16 return (
17 <div className="product-page">
18 <ProductList
19 products={products}
20 selectedId={selectedProduct?.id}
21 onSelect={setSelectedProduct}
22 />
23 <ProductDetails product={selectedProduct} />
24 </div>
25 );
26}
27
28// Child receives data and callbacks
29function ProductList({
30 products,
31 selectedId,
32 onSelect,
33}: {
34 products: Product[];
35 selectedId?: string;
36 onSelect: (product: Product) => void;
37}) {
38 return (
39 <ul>
40 {products.map((product) => (
41 <li
42 key={product.id}
43 className={product.id === selectedId ? 'selected' : ''}
44 onClick={() => onSelect(product)}
45 >
46 {product.name}
47 </li>
48 ))}
49 </ul>
50 );
51}
52
53function ProductDetails({ product }: { product: Product | null }) {
54 if (!product) return <p>Select a product</p>;
55
56 return (
57 <div>
58 <h2>{product.name}</h2>
59 <p>Price: ${product.price}</p>
60 </div>
61 );
62}Form State Lifting#
1// Lift form state for validation and submission
2interface FormData {
3 email: string;
4 password: string;
5}
6
7function LoginPage() {
8 const [formData, setFormData] = useState<FormData>({
9 email: '',
10 password: '',
11 });
12 const [errors, setErrors] = useState<Partial<FormData>>({});
13 const [isSubmitting, setIsSubmitting] = useState(false);
14
15 const updateField = (field: keyof FormData, value: string) => {
16 setFormData((prev) => ({ ...prev, [field]: value }));
17 // Clear error when user types
18 if (errors[field]) {
19 setErrors((prev) => ({ ...prev, [field]: undefined }));
20 }
21 };
22
23 const handleSubmit = async () => {
24 const newErrors: Partial<FormData> = {};
25 if (!formData.email) newErrors.email = 'Email required';
26 if (!formData.password) newErrors.password = 'Password required';
27
28 if (Object.keys(newErrors).length > 0) {
29 setErrors(newErrors);
30 return;
31 }
32
33 setIsSubmitting(true);
34 await submitLogin(formData);
35 setIsSubmitting(false);
36 };
37
38 return (
39 <div>
40 <EmailInput
41 value={formData.email}
42 error={errors.email}
43 onChange={(value) => updateField('email', value)}
44 />
45 <PasswordInput
46 value={formData.password}
47 error={errors.password}
48 onChange={(value) => updateField('password', value)}
49 />
50 <SubmitButton
51 isSubmitting={isSubmitting}
52 onClick={handleSubmit}
53 />
54 </div>
55 );
56}
57
58function EmailInput({
59 value,
60 error,
61 onChange,
62}: {
63 value: string;
64 error?: string;
65 onChange: (value: string) => void;
66}) {
67 return (
68 <div>
69 <input
70 type="email"
71 value={value}
72 onChange={(e) => onChange(e.target.value)}
73 className={error ? 'error' : ''}
74 />
75 {error && <span className="error-message">{error}</span>}
76 </div>
77 );
78}Controlled vs Uncontrolled#
1// Controlled: Parent owns state
2function ControlledSearch({ value, onChange }: {
3 value: string;
4 onChange: (value: string) => void;
5}) {
6 return (
7 <input
8 value={value}
9 onChange={(e) => onChange(e.target.value)}
10 />
11 );
12}
13
14// Uncontrolled: Component owns state, reports changes
15function UncontrolledSearch({ onSearch }: {
16 onSearch: (query: string) => void;
17}) {
18 const [value, setValue] = useState('');
19
20 const handleSubmit = () => {
21 onSearch(value);
22 };
23
24 return (
25 <div>
26 <input
27 value={value}
28 onChange={(e) => setValue(e.target.value)}
29 />
30 <button onClick={handleSubmit}>Search</button>
31 </div>
32 );
33}
34
35// Hybrid: Internal state with external sync
36function HybridInput({
37 defaultValue = '',
38 onCommit,
39}: {
40 defaultValue?: string;
41 onCommit: (value: string) => void;
42}) {
43 const [value, setValue] = useState(defaultValue);
44
45 const handleBlur = () => {
46 onCommit(value);
47 };
48
49 return (
50 <input
51 value={value}
52 onChange={(e) => setValue(e.target.value)}
53 onBlur={handleBlur}
54 />
55 );
56}Callback Patterns#
1// Pass callbacks for child-to-parent communication
2interface Todo {
3 id: string;
4 text: string;
5 completed: boolean;
6}
7
8function TodoApp() {
9 const [todos, setTodos] = useState<Todo[]>([]);
10
11 const addTodo = (text: string) => {
12 setTodos((prev) => [
13 ...prev,
14 { id: Date.now().toString(), text, completed: false },
15 ]);
16 };
17
18 const toggleTodo = (id: string) => {
19 setTodos((prev) =>
20 prev.map((todo) =>
21 todo.id === id ? { ...todo, completed: !todo.completed } : todo
22 )
23 );
24 };
25
26 const deleteTodo = (id: string) => {
27 setTodos((prev) => prev.filter((todo) => todo.id !== id));
28 };
29
30 return (
31 <div>
32 <TodoForm onAdd={addTodo} />
33 <TodoList
34 todos={todos}
35 onToggle={toggleTodo}
36 onDelete={deleteTodo}
37 />
38 </div>
39 );
40}
41
42function TodoForm({ onAdd }: { onAdd: (text: string) => void }) {
43 const [text, setText] = useState('');
44
45 const handleSubmit = (e: React.FormEvent) => {
46 e.preventDefault();
47 if (text.trim()) {
48 onAdd(text.trim());
49 setText('');
50 }
51 };
52
53 return (
54 <form onSubmit={handleSubmit}>
55 <input
56 value={text}
57 onChange={(e) => setText(e.target.value)}
58 placeholder="Add todo..."
59 />
60 <button type="submit">Add</button>
61 </form>
62 );
63}
64
65function TodoList({
66 todos,
67 onToggle,
68 onDelete,
69}: {
70 todos: Todo[];
71 onToggle: (id: string) => void;
72 onDelete: (id: string) => void;
73}) {
74 return (
75 <ul>
76 {todos.map((todo) => (
77 <TodoItem
78 key={todo.id}
79 todo={todo}
80 onToggle={() => onToggle(todo.id)}
81 onDelete={() => onDelete(todo.id)}
82 />
83 ))}
84 </ul>
85 );
86}Avoiding Prop Drilling#
1// Problem: Deep prop drilling
2function App() {
3 const [user, setUser] = useState<User | null>(null);
4
5 return (
6 <Layout user={user}>
7 <Sidebar user={user}>
8 <Navigation user={user}>
9 <UserMenu user={user} onLogout={() => setUser(null)} />
10 </Navigation>
11 </Sidebar>
12 </Layout>
13 );
14}
15
16// Solution 1: Component composition
17function App() {
18 const [user, setUser] = useState<User | null>(null);
19
20 const userMenu = user ? (
21 <UserMenu user={user} onLogout={() => setUser(null)} />
22 ) : (
23 <LoginButton />
24 );
25
26 return (
27 <Layout>
28 <Sidebar>
29 <Navigation userMenu={userMenu} />
30 </Sidebar>
31 </Layout>
32 );
33}
34
35// Solution 2: Context for truly global state
36const UserContext = createContext<{
37 user: User | null;
38 setUser: (user: User | null) => void;
39} | null>(null);
40
41function App() {
42 const [user, setUser] = useState<User | null>(null);
43
44 return (
45 <UserContext.Provider value={{ user, setUser }}>
46 <Layout>
47 <Sidebar>
48 <Navigation>
49 <UserMenu />
50 </Navigation>
51 </Sidebar>
52 </Layout>
53 </UserContext.Provider>
54 );
55}
56
57function UserMenu() {
58 const context = useContext(UserContext);
59 if (!context) throw new Error('UserContext not found');
60
61 const { user, setUser } = context;
62
63 if (!user) return <LoginButton />;
64
65 return (
66 <div>
67 <span>{user.name}</span>
68 <button onClick={() => setUser(null)}>Logout</button>
69 </div>
70 );
71}State Colocation#
1// Keep state as close to where it's used as possible
2
3// BAD: State lifted too high
4function App() {
5 const [searchQuery, setSearchQuery] = useState('');
6 const [isModalOpen, setIsModalOpen] = useState(false);
7 const [selectedTab, setSelectedTab] = useState('home');
8
9 return (
10 <div>
11 <SearchBar query={searchQuery} onChange={setSearchQuery} />
12 <Tabs selected={selectedTab} onChange={setSelectedTab} />
13 <Modal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
14 </div>
15 );
16}
17
18// GOOD: State colocated with usage
19function App() {
20 return (
21 <div>
22 <SearchBar />
23 <Tabs />
24 <ModalContainer />
25 </div>
26 );
27}
28
29function SearchBar() {
30 const [query, setQuery] = useState('');
31 return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
32}
33
34function Tabs() {
35 const [selected, setSelected] = useState('home');
36 return (
37 <div>
38 {['home', 'about', 'contact'].map((tab) => (
39 <button
40 key={tab}
41 className={selected === tab ? 'active' : ''}
42 onClick={() => setSelected(tab)}
43 >
44 {tab}
45 </button>
46 ))}
47 </div>
48 );
49}Best Practices#
When to Lift State:
✓ Multiple components need the same data
✓ Sibling components need to communicate
✓ Parent needs to coordinate children
✓ Data needs to be synchronized
When NOT to Lift:
✓ Only one component uses the state
✓ State is purely UI (hover, focus)
✓ Component is self-contained
✓ Would cause prop drilling
Patterns:
✓ Keep state as low as possible
✓ Lift only when necessary
✓ Use callbacks for upward communication
✓ Use composition to avoid drilling
Performance:
✓ Memoize callbacks with useCallback
✓ Split state to minimize re-renders
✓ Consider Context for deep trees
✓ Use React.memo strategically
Conclusion#
State lifting is fundamental to React's data flow. Lift state to the lowest common ancestor that needs it, use callbacks for child-to-parent communication, and consider composition patterns to avoid prop drilling. For deeply nested state, consider Context or state management libraries.