Practice Questions

1. What problem does TanStack Query solve compared to the manual useEffect + useState pattern for fetching data? Name at least three benefits it provides.

Solution

When fetching data manually, every component needs its own useState for data, loading, and error, plus a useEffect to trigger the fetch. This is repetitive and provides no caching, deduplication, or automatic refetching.

TanStack Query solves this by providing:

  1. Automatic loading and error state managementisPending, isError, and data are returned from useQuery without any manual state variables.
  2. Caching – data fetched with a given query key is cached, so subsequent requests with the same key reuse the cached result instead of re-fetching.
  3. Deduplication – if two components request data with the same query key at the same time, only one network request is made.
  4. Background refetching – stale data is automatically refreshed in the background.

2. Given the following useQuery call, explain what queryKey does and what would happen if two different components used the same key with different query functions.

const { data, isPending, isError } = useQuery({
  queryKey: ["products"],
  queryFn: fetchProducts,
});
Solution

The queryKey identifies a query. TanStack Query uses it to store, retrieve, and deduplicate cached data. If two components use useQuery with the same queryKey, TanStack Query treats them as the same query: it makes only one network request and shares the cached result between both components.

This means the queryKey should uniquely describe the data being fetched. If two components use the same key but different query functions or different variables, that is a bug. They would share cached data that may not match what one of them expects. When the data or parameters differ, the key should differ too (e.g., ["user", userId] includes the userId so each user gets its own cache entry).

3. Write an async function called fetchUserById that takes a numeric id, fetches from https://api.example.com/users/{id}, checks that the response is OK, and returns the parsed JSON. Then write a component that uses useQuery to call this function, handling loading and error states.

Solution
async function fetchUserById(id: number) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.statusText}`);
  }
  return response.json();
}

function UserProfile({ userId }: { userId: number }) {
  const {
    data: user,
    isPending,
    isError,
    error,
  } = useQuery({
    queryKey: ["user", userId],
    queryFn: () => fetchUserById(userId),
  });

  if (isPending) return <p>Loading...</p>;
  if (isError) return <p>Error: {error.message}</p>;

  return <h1>{user.name}</h1>;
}

Note how queryKey includes userId – this ensures each user has its own cache entry. If you navigated to a different user and back, the previously fetched user data would be served from cache.

4. What is prop drilling, and what problem does React Context solve? Give a brief example scenario where prop drilling becomes painful.

Solution

Prop drilling is when you pass data through multiple intermediate components that do not use the data themselves, just to get it to a deeply nested component that needs it. For example, if a root layout holds a theme setting and a deeply nested button needs it, you would have to pass the theme prop through every component in between – even though none of them use it.

React Context solves this by letting you create a value at a high level in the component tree and access it directly from any descendant component, without passing it through intermediate components. You create a context with createContext, provide a value with a provider component, and consume it with useContext in any child.

5. Write a reducer function called listReducer that manages a state object containing an items array of strings. It should handle three action types: "added" (adds a string), "removed" (removes by index), and "cleared" (empties the list). Include the TypeScript types for the state and actions.

Solution
type ListState = {
  items: string[];
};

type ListAction =
  | { type: "added"; value: string }
  | { type: "removed"; index: number }
  | { type: "cleared" };

function listReducer(state: ListState, action: ListAction): ListState {
  switch (action.type) {
    case "added":
      return { items: [...state.items, action.value] };
    case "removed":
      return {
        items: state.items.filter((_, i) => i !== action.index),
      };
    case "cleared":
      return { items: [] };
  }
}

The reducer is a pure function – it takes the current state and an action, and returns a new state object without mutating the original. Each action type is a discriminated union, so TypeScript can narrow the type inside each case.

6. What is the purpose of creating a custom hook that wraps useContext? Write a custom hook called useTheme for a ThemeContext that throws an error if used outside its provider.

Solution

A custom hook wrapping useContext serves two purposes:

  1. Runtime safety – it checks if the context value is null (meaning the component is outside the provider) and throws a descriptive error instead of letting the app fail with a confusing “cannot read property of null” message.
  2. Convenience – consumers import one hook instead of importing both useContext and the context object.
import { createContext, useContext } from "react";

type ThemeContextValue = {
  theme: "light" | "dark";
  toggleTheme: () => void;
};

const ThemeContext = createContext<ThemeContextValue | null>(null);

function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
}

7. Explain the three-part separation of reducer, context, and provider. Why is it beneficial to keep each in its own file?

Solution

The three parts have distinct responsibilities:

  • Reducer – handles state logic. It is a pure function that takes current state and an action and returns new state. It contains no React code.
  • Context – defines what is shared. It declares the shape of the value (the type) and creates the context object with createContext.
  • Provider – connects them. It uses useReducer with the reducer, wraps dispatch in convenience functions, computes derived values, and passes everything through the context to children.

Keeping them separate makes each piece easier to understand, test, and reuse. The reducer can be unit-tested without React. The context type documents the public API. The provider can be swapped out (for example, replacing Context with TanStack Store) without changing the reducer logic.

8. What is the re-render problem with React Context? How does TanStack Store’s selector-based subscription model address it?

Solution

With React Context, when the context value changes, every mounted component that calls useContext on that context re-renders – even if the component only uses a small piece of the value. For example, if a context provides both a list of items and a total count, a component that only displays the count will still re-render whenever any item changes.

TanStack Store solves this with selectors. When you call useStore(store, selector), the component subscribes only to the value returned by the selector function. It re-renders only when that specific derived value changes. For example:

// Only re-renders when totalItems changes, not on every cart update
const totalItems = useStore(cartStore, (state) =>
  state.items.reduce((sum, item) => sum + item.quantity, 0),
);

9. Compare React Context with TanStack Store by listing at least three structural differences in how you set up and use each approach.

Solution
  1. Provider vs. module-level store – React Context requires a provider component wrapping the tree. TanStack Store creates a store at the module level; no provider is needed.
  2. Hooks vs. plain functions for updates – With Context, update functions (like addToCart) are typically defined inside the provider and accessed via a hook. With TanStack Store, update functions are plain exported functions that call store.setState directly – they can be called from anywhere, not just inside React components.
  3. Reducer + dispatch vs. setState – Context often pairs with useReducer, where you dispatch typed action objects. TanStack Store uses setState with an updater function, similar to useState but for external state.
  4. Subscription granularity – Context re-renders all consumers on any change. TanStack Store’s useStore accepts a selector so components only re-render when their selected slice changes.

10. Write a TanStack Store that manages a list of notification objects (each with id, message, and read boolean). Include functions to add a notification, mark one as read, and clear all read notifications. Then show how a component would subscribe to only the unread count.

Solution
import { createStore } from "@tanstack/store";

type Notification = {
  id: number;
  message: string;
  read: boolean;
};

type NotificationState = {
  items: Notification[];
  nextId: number;
};

export const notificationStore = createStore<NotificationState>({
  items: [],
  nextId: 1,
});

export function addNotification(message: string) {
  notificationStore.setState((state) => ({
    items: [...state.items, { id: state.nextId, message, read: false }],
    nextId: state.nextId + 1,
  }));
}

export function markAsRead(id: number) {
  notificationStore.setState((state) => ({
    ...state,
    items: state.items.map((n) => (n.id === id ? { ...n, read: true } : n)),
  }));
}

export function clearRead() {
  notificationStore.setState((state) => ({
    ...state,
    items: state.items.filter((n) => !n.read),
  }));
}

A component subscribing to only the unread count:

import { useStore } from "@tanstack/react-store";
import { notificationStore } from "./notification-store";

function UnreadBadge() {
  const unreadCount = useStore(
    notificationStore,
    (state) => state.items.filter((n) => !n.read).length,
  );

  if (unreadCount === 0) return null;
  return <span className="badge">{unreadCount}</span>;
}

This component only re-renders when the unread count changes. Marking a notification as read when it was already read leaves the unread count the same, so the badge does not re-render.

11. A component computes a total from a list of items on every render. Is this derived value stored in state? Why or why not? Write a short example showing how to compute a derived value from useReducer state.

Solution

No, derived values should not be stored in state. They are computed from existing state on each render. Storing them in state would create a synchronization problem – you would need to remember to update the derived value every time the source state changes, which is error-prone.

function ItemList() {
  const [state, dispatch] = useReducer(itemReducer, { items: [] });

  // Derived values -- computed on every render, not stored in state
  const totalQuantity = state.items.reduce(
    (sum, item) => sum + item.quantity,
    0,
  );
  const totalPrice = state.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0,
  );

  return (
    <div>
      <p>Items: {totalQuantity}</p>
      <p>Total: ${totalPrice.toFixed(2)}</p>
    </div>
  );
}

Since totalQuantity and totalPrice can always be computed from state.items, they do not need their own state. React will recompute them whenever state changes and the component re-renders.

12. Describe a general pattern for persisting TanStack Store state to localStorage. Where would you load saved data, and where would you save it? Why can you call localStorage.setItem directly inside mutation functions instead of using a React hook like useEffect?

Solution

The pattern has two parts:

  1. Load on initialization — read from localStorage when the store is created, using the result as the initial state. Include a try/catch so that corrupt or missing data falls back to a sensible default.

  2. Save inside each mutation — after computing the new state in each mutation function, write it to localStorage before returning the new state.

const STORAGE_KEY = "my-data";

function load(): Item[] {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    return raw ? JSON.parse(raw) : [];
  } catch {
    return [];
  }
}

function save(items: Item[]) {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}

export const myStore = createStore<State>({ items: load() });

export function addItem(item: Item) {
  myStore.setState((state) => {
    const items = [...state.items, item];
    save(items);
    return { items };
  });
}

You can call localStorage.setItem directly because TanStack Store state lives outside React — mutation functions are plain functions, not hooks. You have the new state in hand inside the mutation, so you can persist it right there. By contrast, React state (via useReducer or useState) requires useEffect to observe changes after a re-render, because you cannot run side effects during rendering.