React

React is a library for building UIs as a tree of components. Each component is a function that takes props and returns JSX. When state changes, React re-runs the function and reconciles the new output against the previous DOM.


🟢 Junior

JSX and Rendering

JSX is syntactic sugar for React.createElement calls. Babel or the React compiler transforms it before the browser sees it.

function Greeting({ name, isAdmin }) {
  return (
    <div className="greeting">
      <h1>Hello, {name}!</h1>
      {isAdmin && <span className="badge">Admin</span>}
    </div>
  );
}

className is used instead of class because class is a reserved keyword in JavaScript. Similarly, htmlFor instead of for.

Lists must have a unique key prop so React can track items during reconciliation. Use a stable ID, not the array index (index keys cause bugs when the list is sorted or filtered).

function UserList({ users }) {
  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

useState

useState stores a value between renders. Calling the setter triggers a re-render with the new value.

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
    </div>
  );
}

Always use the functional form setCount(prev => prev + 1) when the new value depends on the old value. This avoids stale closure bugs in async handlers or rapid updates.

useEffect

useEffect runs after the component renders. The dependency array controls when it re-runs.

function Profile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;

    async function load() {
      setLoading(true);
      const data = await fetchUser(userId);
      if (!cancelled) {
        setUser(data);
        setLoading(false);
      }
    }

    load();
    return () => { cancelled = true; }; // cleanup prevents stale state on fast prop changes
  }, [userId]);

  if (loading) return <p>Loading…</p>;
  return <p>{user?.name}</p>;
}

An empty dependency array [] means “run once after mount.” No array means “run after every render.” Both are usually wrong for data-fetching — use a library like TanStack Query instead.

Props and Component Composition

React components communicate downward via props. Lifting state up is the mechanism for sibling components to share data — both siblings receive state and a setter from their common ancestor.

function SearchableList({ items }) {
  const [query, setQuery] = useState('');
  const filtered = items.filter(i => i.name.toLowerCase().includes(query));

  return (
    <div>
      <SearchInput value={query} onChange={setQuery} />
      <ItemList items={filtered} />
    </div>
  );
}

🟡 Medior

useReducer

When multiple state values are related or the next state depends on the previous in a complex way, useReducer is cleaner than multiple useState calls.

const initialState = { count: 0, step: 1, history: [] };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + state.step, history: [...state.history, state.count] };
    case 'setStep':
      return { ...state, step: action.payload };
    case 'reset':
      return initialState;
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>Count: {state.count} | Step: {state.step}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <input type="number" value={state.step}
        onChange={e => dispatch({ type: 'setStep', payload: +e.target.value })} />
    </div>
  );
}

useContext and Context API

Context distributes a value to all descendants without passing props through every layer.

const ThemeContext = createContext('light');

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

function Button({ children }) {
  const { theme } = useContext(ThemeContext);
  return <button className={`btn btn-${theme}`}>{children}</button>;
}

Context re-renders every consumer when the value changes. For high-frequency updates (mouse position, scroll), use a dedicated state manager or split contexts.

useMemo and useCallback

Both are optimization tools. useMemo memoizes a computed value. useCallback memoizes a function reference.

function ProductList({ products, category }) {
  const filtered = useMemo(
    () => products.filter(p => p.category === category),
    [products, category]
  );

  const handleClick = useCallback((id) => {
    openProductModal(id);
  }, []); // stable reference — openProductModal is stable

  return filtered.map(p => (
    <ProductCard key={p.id} product={p} onClick={handleClick} />
  ));
}

Only add these when you have measured a performance problem. Premature memoization adds complexity and can itself be slow if the dependencies change often.

Custom Hooks

Custom hooks extract reusable stateful logic. They always start with use (this is enforced by the linter).

function useLocalStorage(key, defaultValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : defaultValue;
    } catch {
      return defaultValue;
    }
  });

  const setAndStore = useCallback((next) => {
    const val = typeof next === 'function' ? next(value) : next;
    setValue(val);
    localStorage.setItem(key, JSON.stringify(val));
  }, [key, value]);

  return [value, setAndStore];
}
function Settings() {
  const [lang, setLang] = useLocalStorage('language', 'en');
  return <select value={lang} onChange={e => setLang(e.target.value)}>...</select>;
}

React.memo and forwardRef

React.memo skips re-rendering a component if its props are shallowly equal to the previous render.

const ExpensiveChart = React.memo(function Chart({ data }) {
  return <canvas>{/* heavy rendering */}</canvas>;
});

forwardRef passes a ref through a component to a DOM node or child component:

const Input = forwardRef(function Input({ label, ...props }, ref) {
  return (
    <label>
      {label}
      <input ref={ref} {...props} />
    </label>
  );
});

function Form() {
  const inputRef = useRef(null);
  useEffect(() => { inputRef.current?.focus(); }, []);
  return <Input ref={inputRef} label="Name" />;
}

🔴 Senior

Concurrent Rendering and useTransition

React 18 introduced concurrent rendering — the scheduler can interrupt, pause, and resume renders. This prevents expensive renders from blocking urgent updates.

useTransition marks a state update as non-urgent. React renders the urgent update immediately and defers the transition.

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(e) {
    const q = e.target.value;
    setQuery(q); // urgent — update input immediately

    startTransition(() => {
      setResults(searchProducts(q)); // deferred — can be interrupted
    });
  }

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      {isPending ? <Spinner /> : <ResultList items={results} />}
    </div>
  );
}

Suspense and Data Fetching

Suspense catches loading states from child components. When a child “suspends” (throws a Promise), React renders the fallback and resumes when the Promise resolves.

TanStack Query and Relay integrate with Suspense. With React 18’s server rendering, you can stream UI in chunks.

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Suspense fallback={<HeaderSkeleton />}>
        <Header /> {/* may suspend to load user */}
      </Suspense>
      <Suspense fallback={<FeedSkeleton />}>
        <Feed />   {/* may suspend to load posts */}
      </Suspense>
    </Suspense>
  );
}

Performance Profiling

Use React DevTools Profiler to find which components re-render on each interaction and how long they take. Key patterns to fix:

Accidental re-renders: a parent re-renders and all children re-render even if their props didn’t change. Fix with React.memo.

Expensive computations on every render: fix with useMemo.

Unstable function references: a new function object is created every render, breaking React.memo on children that receive it as a prop. Fix with useCallback.

Large context value objects: recreated every render, causing all consumers to re-render. Fix by memoizing the value: useMemo(() => ({ theme, setTheme }), [theme]).

State Management Patterns

For global state across many unrelated components, choose based on update frequency and complexity:

Context + useReducer — fits for low-frequency updates (theme, auth, language). Simple, zero dependencies.

Zustand — small, fast store with no boilerplate. Consumers only re-render when their selected slice changes.

TanStack Query — manages server state (loading, caching, refetching, pagination). Removes the need to manually manage async state in useEffect.

Jotai / Recoil — atom-based fine-grained reactivity. Good when many components need different slices of shared state.

Senior Gotchas

Calling hooks conditionally or inside loops violates the rules of hooks and causes state to be read from the wrong slot between renders. The ESLint plugin catches this at dev time.

Avoid useEffect for derived state — compute it inline during render instead. useEffect for derived state causes an extra render cycle.

StrictMode in development intentionally double-invokes render functions and effects to surface impure components and missing cleanups. Code that breaks in StrictMode is broken — fix it, don’t disable StrictMode.

Object and array literals in JSX create new references every render, breaking React.memo. Move them outside the component or memoize them.

const EMPTY_ARRAY = [];
function List() {
  return <Child items={EMPTY_ARRAY} />; // stable reference
}