Insights into Company Bills & Payments
← React

State Management

Understand the idea. Compare it. Explain it. Test yourself.

Question 1 of 5

What is lifting state up?

Solution and explanation

What it is

When two or more components need the same changing data, you move ("lift") that state to their **closest common parent**. The parent holds the state and passes the value and a setter (or callback) down as props. There is then a single source of truth.

Example: filter panel and result list

function JobSearchPage() {
  const [filters, setFilters] = useState({ city: '', remote: false });
  return (
    <div className="layout">
      <FilterPanel filters={filters} onChange={setFilters} />
      <JobResults filters={filters} />
    </div>
  );
}

function FilterPanel({ filters, onChange }) {
  return (
    <label>
      <input type="checkbox" checked={filters.remote}
             onChange={e => onChange({ ...filters, remote: e.target.checked })} />
      Remote only
    </label>
  );
}

Before lifting, FilterPanel and JobResults each had their own copy and drifted out of sync. After lifting, both read the same object.

How to decide where state lives

  1. List every component that reads or changes the value.
  2. Find their nearest common ancestor.
  3. Put the state there (or higher only if needed).

When lifting is not enough

If the common parent is many levels up and intermediate components only pass props along (**prop drilling**), consider:

  • **Composition:** pass components as children so the middle layers do not need the data.
  • **Context:** for app-wide values (current user, theme, language).
  • **A store** (Zustand, Redux Toolkit) for large, frequently changing shared state.
  • **URL state:** filters and page numbers belong in the query string (?city=Pune&page=2) so they survive refresh and can be shared.

Summary

Lift shared state to the closest common parent and pass it down; this keeps one source of truth and predictable one-way data flow.

Practice explaining this
Question 2 of 5

When should Context be used?

Solution and explanation

What it is

Context passes a value through the component tree without threading props through every level.

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const value = useMemo(() => ({
    user,
    login: async (email, pwd) => setUser(await api.login(email, pwd)),
    logout: () => { api.logout(); setUser(null); },
  }), [user]);
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export const useAuth = () => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
  return ctx;
};

// Anywhere below the provider:
const { user, logout } = useAuth();

Good uses

  • Logged-in user and permissions
  • Theme (light/dark), language, currency
  • Feature flags, configuration
  • Dependencies such as an API client

These change **rarely** and are read **widely**.

Poor uses

  • Fast-changing data (text input, mouse position, a large list being edited). Every consumer re-renders when the context value changes, even if it uses only part of it.
  • Server data: use TanStack Query or SWR, which add caching and refetching.
  • Replacing all props: explicit props make components easier to understand and reuse.

Performance tips

  • Memoise the value object (useMemo), otherwise it is new on every render.
  • Split contexts: UserContext and ThemeContext separately, or separate state and dispatch contexts.
  • For large shared state with selectors use Zustand or Redux Toolkit.

Summary

Use Context for global, rarely changing values such as auth, theme and locale; avoid it for high-frequency updates and server cache.

Practice explaining this
Question 3 of 5

Controlled versus uncontrolled components?

Solution and explanation

Controlled components

The form value lives in React state; the input shows that state and every keystroke updates it.

function EmailField() {
  const [email, setEmail] = useState('');
  const invalid = email && !email.includes('@');
  return (
    <>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      {invalid && <small>Enter a valid email</small>}
    </>
  );
}

Advantages: instant validation, formatting (e.g. uppercase PAN), disabling the submit button, conditional fields.

Uncontrolled components

The DOM keeps the value; React reads it only when needed through a ref or the form data.

function ResumeUpload() {
  const fileRef = useRef(null);
  const submit = e => {
    e.preventDefault();
    const data = new FormData(e.target);          // or fileRef.current.files[0]
    upload(data);
  };
  return (
    <form onSubmit={submit}>
      <input name="fullName" defaultValue="" />
      <input type="file" name="resume" ref={fileRef} />
      <button>Upload</button>
    </form>
  );
}

Use defaultValue (not value) for the initial value. File inputs are always uncontrolled.

Comparison

ControlledUncontrolled
Source of truthReact stateDOM
Re-render per keystrokeyesno
Live validationeasyon submit
Codemoreless

In practice

Form libraries such as **React Hook Form** use uncontrolled inputs with refs for performance, while still offering validation (often with Zod or Yup). A common warning, "A component is changing an uncontrolled input to be controlled", appears when value starts as undefined; initialise with ''.

Summary

Controlled inputs are driven by state (more control); uncontrolled inputs keep their own value and are read on demand (less code, fewer renders).

Practice explaining this
Question 4 of 5

How do reducers manage complex state?

Solution and explanation

What a reducer is

A reducer is a pure function (state, action) => newState. With useReducer all state transitions are described in one place, which is clearer than many useState calls changing related values.

const initial = { items: [], status: 'idle', error: null, page: 1 };

function jobsReducer(state, action) {
  switch (action.type) {
    case 'fetch_start':   return { ...state, status: 'loading', error: null };
    case 'fetch_success': return { ...state, status: 'done', items: action.items };
    case 'fetch_error':   return { ...state, status: 'error', error: action.error };
    case 'next_page':     return { ...state, page: state.page + 1 };
    case 'remove':        return { ...state, items: state.items.filter(j => j.id !== action.id) };
    default: throw new Error(`Unknown action ${action.type}`);
  }
}

function Jobs() {
  const [state, dispatch] = useReducer(jobsReducer, initial);
  useEffect(() => {
    dispatch({ type: 'fetch_start' });
    api.jobs(state.page)
      .then(items => dispatch({ type: 'fetch_success', items }))
      .catch(error => dispatch({ type: 'fetch_error', error }));
  }, [state.page]);
  ...
}

Why it helps

  • **Impossible states disappear:** loading, error and data change together in one transition.
  • **Testable:** a reducer is a plain function; test it without rendering.
  • **Readable:** event handlers say *what happened* (dispatch({type:'remove', id})), the reducer says *how state changes*.
  • dispatch has a stable identity, so it is safe to pass down or use in effects.

useState or useReducer?

Use useStateUse useReducer
independent simple valuesseveral values that change together
few update pathsmany actions (cart, multi-step form, wizard)

Scaling up

useReducer + Context gives a small global store. **Redux Toolkit** uses the same reducer idea with createSlice, Immer (write "mutating" code safely) and DevTools time-travel debugging.

Summary

Reducers centralise state transitions in a pure function; use them when state is complex or several values must change together.

Practice explaining this
Question 5 of 5

How do server-state libraries differ from global stores?

Solution and explanation

Two kinds of state

  • **Client state:** owned by the browser: open modal, selected tab, form draft, theme.
  • **Server state:** owned by the backend: jobs, user profile, applications. It can change without you knowing, needs fetching, caching, refetching and can be stale.

Global stores (Redux, Zustand, Context) are designed for client state. Server-state libraries (**TanStack Query**, **SWR**, RTK Query, Apollo for GraphQL) are designed for server state.

What a server-state library gives you

const { data: jobs, isLoading, error } = useQuery({
  queryKey: ['jobs', { city, page }],
  queryFn: () => api.jobs({ city, page }),
  staleTime: 60_000,
});

const queryClient = useQueryClient();
const applyMutation = useMutation({
  mutationFn: jobId => api.apply(jobId),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['applications'] }),
});
  • **Caching by key:** two components asking for the same jobs share one request.
  • **Deduplication** of simultaneous requests.
  • **Background refetch** on window focus or reconnect; stale-while-revalidate.
  • **Retries** with backoff, pagination and infinite scroll helpers.
  • **Mutations** with cache invalidation or optimistic updates and rollback.
  • Loading and error states without writing reducers.

With only a global store

You would write actions for start/success/failure, keep loading flags, decide when data is stale, avoid duplicate requests and refresh manually: a lot of repetitive code that is easy to get wrong.

Recommended split

DataTool
API dataTanStack Query / SWR
URL-shareable filtersrouter search params
Small app-wide client stateContext or Zustand
Large complex client stateRedux Toolkit
Local UI stateuseState

Summary

Server-state libraries manage remote data (cache, sync, refetch, mutations); global stores manage client-owned state. Most apps need both, used for their own purpose.

Practice explaining this