React & Modern Frontend Frameworks

Hooks Deep Dive & State Management

5 min read

React hooks are the backbone of modern React development. Interviewers test not just your ability to use hooks, but your understanding of their rules, pitfalls, and when to choose alternatives. Modern React codebases (React 18+/19) rarely use class components or legacy lifecycle methods (componentDidMount, componentDidUpdate) — expect interviews to focus almost entirely on hook-based code.

useEffect Cleanup Pitfalls

The most common source of bugs in React interviews:

// WRONG — memory leak, no cleanup
useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  // Missing cleanup! The interval runs forever
}, []);

// CORRECT — cleanup on unmount
useEffect(() => {
  const interval = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(interval);
}, []);
// WRONG — stale closure over state
useEffect(() => {
  const handler = () => {
    console.log(count); // Always logs the initial value
  };
  window.addEventListener('scroll', handler);
  return () => window.removeEventListener('scroll', handler);
}, []); // Empty deps = stale closure

// CORRECT — use ref for latest value
const countRef = useRef(count);
countRef.current = count;

useEffect(() => {
  const handler = () => {
    console.log(countRef.current); // Always current
  };
  window.addEventListener('scroll', handler);
  return () => window.removeEventListener('scroll', handler);
}, []);

useRef vs. useState

Know when to use each:

FeatureuseStateuseRef
Triggers re-renderYesNo
Persists across rendersYesYes
Available in renderCurrent value.current
Use forUI state, displayed valuesTimers, DOM refs, previous values, mutable flags
// Classic interview question: implement usePrevious
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

function Counter() {
  const [count, setCount] = useState(0);
  const prevCount = usePrevious(count);

  return (
    <p>Now: {count}, Before: {prevCount}</p>
  );
}

Custom Hooks

Designing custom hooks demonstrates senior-level React skills:

// useDebounce: debounce a rapidly changing value
function useDebounce(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// useLocalStorage: persist state to localStorage
function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const item = localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

// Usage
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);
  const [history, setHistory] = useLocalStorage('search-history', []);

  useEffect(() => {
    if (debouncedQuery) {
      fetchResults(debouncedQuery);
      setHistory(prev => [...prev, debouncedQuery].slice(-10));
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

State Management: When to Use What

This is a common interview discussion topic:

React Context

// Good for: theme, locale, auth state (infrequent updates)
const ThemeContext = createContext('light');

function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Page />
    </ThemeContext.Provider>
  );
}

// BAD for: frequently updating state (causes all consumers to re-render)

Zustand (lightweight external store)

// Good for: shared state that updates frequently, simple API
import { create } from 'zustand';

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

function Counter() {
  // Only re-renders when count changes, not other store values
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  return <button onClick={increment}>{count}</button>;
}

TanStack Query (server state)

// Good for: API data fetching, caching, synchronization
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
  });

  if (isLoading) return <Skeleton />;
  if (error) return <Error message={error.message} />;
  return <div>{data.name}</div>;
}

The follow-up you should expect after describing Context: "why not just use Context for everything?" The answer is a specific mechanism, not a preference.

Context vs. an external store — the re-render mechanism

built in

React Context

Update granularityEvery consumer, every time
Extra dependencyNone
Breaks down whenThe value changes often
Pros
  • No dependency, no store to set up, and it is the answer interviewers expect for theme, locale, and auth
  • The provider boundary is visible in the tree, so where a value comes from is obvious when reading the code
  • Works unchanged in Server Component trees as long as the provider itself is a Client Component
Cons
  • A new context value re-renders every consumer, regardless of which field they read — there is no selector, so `{user, theme, setTheme}` re-renders the user badge when the theme toggles
  • The usual workaround is splitting into several contexts, which multiplies providers and moves the problem rather than solving it
  • An object or arrow function passed inline as `value` is a new reference on every parent render, so the provider re-renders consumers even when nothing changed
selector-based

External store (Zustand, Jotai, Redux Toolkit)

Update granularityOnly components whose selector output changed
Extra dependencyYes
Breaks down whenIt is holding server data
Pros
  • Subscribing with a selector means a component re-renders only when the slice it reads changes — this is the actual reason to reach for one
  • State can be read and written outside the React tree, which makes it usable from event handlers, workers, and tests without rendering anything
  • No provider nesting, so the tree stays flat as the number of state domains grows
Cons
  • Global mutable state is easy to write from anywhere, which is exactly how it becomes hard to reason about
  • A selector returning a fresh object each call defeats the whole mechanism — this is the most common bug and it looks like the library is broken
  • Teams routinely put server data in it and then hand-roll caching, invalidation, and refetching that TanStack Query already does properly

Decision Matrix

NeedSolution
Theme, locale, authReact Context
Form stateuseState / useReducer (local)
Complex local stateuseReducer
Shared client stateZustand or Jotai
Server dataTanStack Query
Large-scale global stateRedux Toolkit (if already in stack)

Interview tip: When asked about state management, demonstrate that you choose the right tool for the specific need rather than defaulting to one solution for everything. This shows architectural thinking.

Next, we'll explore Server Components and the Next.js architecture that's reshaping frontend development. :::

Quiz

Module 3: React & Modern Frontend Frameworks

Take Quiz
Was this lesson helpful?

Sign in to rate