Course topics

By WebNest Studio Engineering Team

React Tutorial

State Management Concepts in React

Not all state needs the same treatment. React applications typically deal with three different scopes of state: state that belongs to a single component, state that must be shared between a few related components, and state that many unrelated parts of the app need. Choosing the right tool for each scope keeps an application simple instead of over-engineered.

The general progression is: start with local useState, lift it up when siblings need to share it, and only reach for an external state library once prop drilling or context re-render costs become a real problem — not by default.

Why it matters

Where state lives is the main architectural decision in a React application. Keep it too low and components cannot share it; keep everything in a global store and every change touches the whole application. Most applications need far less global state than people assume, and knowing the options avoids both mistakes.

When Local useState Is Enough

If a piece of state is only read and updated by one component (and maybe its direct children via props), local useState or useReducer is the right and simplest choice. Examples include whether a dropdown is open, the current value of a text input, or a toggle for showing/hiding a section. Reaching for global state here adds indirection and complexity with no real benefit.

Lifting State Up

When two or more sibling components need to reflect or modify the same piece of state, the standard React pattern is "lifting state up": move the state to their nearest common parent, and pass the value down as props along with a setter function (or callback) to update it. This keeps the data flow explicit and predictable — data always flows down through props, and changes flow up through callback functions — without introducing context or an external library.

External State Libraries (Redux, Zustand, and Similar)

When state needs to be read and updated from many unrelated components scattered across the tree, and Context's re-render costs or boilerplate become a burden, an external state management library becomes worth considering. Redux centralizes state in a single store, updated only through dispatched actions and pure reducer functions, which gives strong predictability and excellent debugging tools but comes with more setup and ceremony. Zustand takes a lighter approach: state lives in a small store created with a hook-like function, requires far less boilerplate, and components subscribe only to the specific slices of state they use, which avoids the "everything re-renders" problem that plain Context can have.

  • Local useState — single component owns and uses the state.
  • Lifted state / Context — a handful of related components, or broadly-needed but rarely-changing data (theme, auth).
  • Redux / Zustand — large apps with complex, frequently-updated state shared across many unrelated components.

Examples

Lifting state up so two sibling components share one source of truth

JavaScript
import { useState } from 'react';

function TemperatureInput({ label, value, onChange }) {
  return (
    <label>
      {label}:{' '}
      <input
        type="number"
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
      />
    </label>
  );
}

function TemperatureSummary({ celsius }) {
  const fahrenheit = (celsius * 9) / 5 + 32;
  return <p>{celsius}°C is {fahrenheit}°F</p>;
}

export default function TemperatureConverter() {
  // State lives in the common parent, not in either child
  const [celsius, setCelsius] = useState(20);

  return (
    <div>
      <TemperatureInput label="Celsius" value={celsius} onChange={setCelsius} />
      <TemperatureSummary celsius={celsius} />
    </div>
  );
}
Output
Renders a number input pre-filled with 20 and a paragraph reading "20°C is 68°F". Typing a new value in the input immediately updates the summary paragraph, because both components read from the same lifted state.

A minimal Zustand store, for comparison with Context/Redux

JavaScript
// store.js
import { create } from 'zustand';

export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  reset: () => set({ count: 0 }),
}));

// Counter.jsx
import { useCounterStore } from './store';

function Counter() {
  // Only re-renders when "count" changes, not on unrelated store updates
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return <button onClick={increment}>Count: {count}</button>;
}

export default Counter;
Output
Renders a button showing "Count: 0". Each click increments the shared store value; any other component in the app that also calls useCounterStore((state) => state.count) updates in sync, without prop drilling or a Context Provider.

Common Mistakes

  • Reaching for Redux or another external library for state that is only ever used inside one component.
  • Lifting state all the way to the top-level App component when it is only shared between two nearby siblings, making prop chains longer than necessary.
  • Storing derived values (like a computed total) as separate state instead of calculating them during render from existing state, leading to state that can go out of sync.
  • Using Context for state that changes very frequently across a wide tree, then being surprised by widespread re-renders that a library like Zustand would have avoided via selective subscriptions.

Exercise

Do this in src/App.jsx of a Vite + React project. Two sibling components need the same value: TemperatureInput edits a temperature in Celsius, and BoilingVerdict says whether water would boil at it. Lift the state up into App, passing the value and a change handler to the input and the value to the verdict.

Expected result: typing 100 or more shows "The water would boil"; a lower number shows "The water would not boil".

JavaScript · starter code
import { useState } from "react";

function TemperatureInput({ value, onChange }) {
  // TODO: a number input showing value and reporting changes through onChange
  return null;
}

function BoilingVerdict({ celsius }) {
  // TODO: one of the two messages, depending on celsius >= 100
  return null;
}

export default function App() {
  // TODO: the shared temperature state

  return (
    <div>
      {/* TODO: TemperatureInput and BoilingVerdict, both fed from the state */}
    </div>
  );
}
Show hints
  • The state lives in the closest common parent of the components that need it.
  • The input is controlled by the parent: it receives value and calls onChange with the new number.
Show solution
JavaScript · solution
import { useState } from "react";

function TemperatureInput({ value, onChange }) {
  return (
    <input
      type="number"
      value={value}
      onChange={(event) => onChange(Number(event.target.value))}
    />
  );
}

function BoilingVerdict({ celsius }) {
  return <p>{celsius >= 100 ? "The water would boil" : "The water would not boil"}</p>;
}

export default function App() {
  const [celsius, setCelsius] = useState(20);

  return (
    <div>
      <TemperatureInput value={celsius} onChange={setCelsius} />
      <BoilingVerdict celsius={celsius} />
    </div>
  );
}

Quick quiz

1. Two sibling components need the same piece of state. Where should it live?

  • A. In both, kept in step with effects
  • B. In a global variable
  • C. In their closest common parent
  • D. In local storage
Show answer

C. The parent passes the value down and a function to change it. This is called lifting state up.

2. Which kind of data is usually best kept out of a global client store?

  • A. Data fetched from the server, which a data-fetching library caches better
  • B. The current theme
  • C. Whether a global modal is open
  • D. The signed-in user
Show answer

A. Server data has its own concerns: caching, staleness and refetching.

3. When is a state management library such as Redux or Zustand worth adding?

  • A. In every project from the first day
  • B. Never
  • C. Only for forms
  • D. When much state is shared across distant parts of a large application and props or Context have become unwieldy
Show answer

D. Start with local state and lift it only as far as needed.

Interview questions

How do you decide where a piece of state should live?

Show answer

Keep it as close as possible to where it is used. If only one component needs it, it is local state. If several need it, lift it to their nearest common parent. If components across the whole application need it and it changes rarely, use Context. If it is server data, keep it in a data-fetching cache. A global store is for complex client state shared widely. Values that can be computed from other state should not be state at all.

What is the difference between Context and Redux?

Show answer

Context is a mechanism for passing a value down the tree without props; it has no opinion about how the value is updated, and every consumer re-renders when it changes. Redux is a state management library with a single store, updates described as actions and applied by reducers, selectors that let a component subscribe to just the slice it needs, and tools for inspecting every change. Context suits simple, rarely changing data; a store suits complex, frequently changing shared state.

Key Points to Remember

  • Start with local useState; it is sufficient for state a single component owns.
  • Lift state to the nearest common parent when sibling components need to share it.
  • Context suits broadly-needed, infrequently-changing data; it is not a full state-management solution.
  • Redux and Zustand solve large-scale shared state, with Redux favoring strict predictability and Zustand favoring minimal boilerplate and selective re-renders.

Practice in the playground

Write and run your own code for this topic, or start from the exercise or an example. Shows a live preview of plain HTML, CSS and JavaScript. JSX needs a React project on your computer. Your code for each topic is kept in this browser.

Run the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

These examples need a project environment on your computer, so they can be copied but not run here. Codelab runs Python, FastAPI backends with SQLite, plain Java 17 and HTML/CSS/JavaScript.

WebNest Studio

Written by

WebNest Studio Engineering Team

Practical lessons from developers working with Java, Python, React, databases and production systems.

Led by Vansh Duggal, Founder, WebNest Studio · Full-stack & software engineering