← Back to list

React #10 — Typescript — useReducer

In this article, we will build the data layer for a quiz app using React and TypeScript. The flow is simple: JSON Server will provide mock…

quizzesforyou.com · 2026-06-23 16:41 · 0 claps · 3.7 min read
#react #typescript #mynotes #zod
Open on Medium ↗
Wiki topics: 🌐 · Web Development

React #10 — Typescript — useReducer

In this article, we will build the data layer for a quiz app using React and TypeScript. The flow is simple: JSON Server will provide mock quiz questions, Zod will validate the API response, a service function will fetch the data, and a custom hook will use useReducer to manage the request state.

[embed]GitHub - React-tuts/reducer-quiz Contribute to React-tuts/reducer-quiz development by creating an account on GitHub.github.com

[embed]List: React — TS | Curated by quizzesforyou.com | Medium React — TS · 9 stories on Mediummedium.com

What we are building

The app needs to load a list of quiz questions from an API and track whether the request is loading, ready, failed, active, or finished. This is a perfect use case for useReducer, because the state has a few related values that should change together.

  • JSON Server exposes the quiz data from data/questions.json.
  • Zod schemas define the expected shape of each question.
  • A service function fetches and validates the data.
  • A custom hook stores the questions, status, and error message in one reducer state.

1. Create a JSON Server

First, create a data folder and add a questions.json file inside it. This file will act as our small local database while we build the front end.

Install JSON Server:

npm i json-server

Then add a script in package.json:

"server": "json-server --watch data/questions.json --port 8000"

Now run the server:

npm run server

JSON Server will start on port 8000. If your JSON file has a questions key, the endpoint will be available at:

http://localhost:8000/questions

This gives us a realistic API while keeping the setup lightweight.

2. Create Schemas and Types

TypeScript helps us describe what we expect in our code, but it does not validate data at runtime. An API can still return unexpected data. That is where Zod is useful: it checks the response while the app is running.

Create a schema for one question:

import z from "zod";
export const questionSchema = z.object({
  question: z.string(),
  options: z.array(z.string()),
  correctOption: z.number(),
  points: z.number(),
  id: z.string()
});

Then create a schema for the full list of questions:

import z from "zod";
import { questionSchema } from "./question.schema";
export const questionsSchema = z.array(questionSchema);

Finally, infer the TypeScript type from the schema:

import z from "zod";
import { questionSchema } from "../schemas";
export type Question = z.infer<typeof questionSchema>;

This keeps the schema and TypeScript type connected. If the schema changes later, the type updates with it.

3. Create the Questions Service

The service is responsible for calling the API, checking the HTTP response, validating the JSON, and returning clean data to the rest of the app.

import { API_Config } from "../appconfig";
import { questionsSchema } from "../shared/schemas";
import { Question } from "../shared/types/question.types";
/**
 * @param signal
 * @returns Promise<Question[]>
 */
export const getQuestions = async (signal?: AbortSignal): Promise<Question[]> => {
  const response = await fetch(API_Config.baseUrl + "/questions", { signal });
  if (!response.ok) {
    throw new Error("HTTP error " + response.status);
  }
  const data = await response.json();
  const result = questionsSchema.safeParse(data);
  if (!result.success) {
    console.error(result.error);
    throw new Error("Invalid API Response");
  }
  return result.data;
};

The important part here is safeParse. Zod has two common validation methods:

  • parse throws an error when the data is invalid.
  • safeParse returns a result object, so we can handle validation failure without crashing immediately.

The result looks like this:

{
  success: boolean;
  data?: T;
  error?: ZodError;
}

This makes the service predictable: either it returns valid Question[] data, or it throws an error that the UI can handle.

4. Create a Custom Hook with useReducer

Now we can create a custom hook called useQuestions. This hook owns the request state and exposes it to the UI.

Instead of keeping questions, status, and error in separate state variables, we keep them together in one reducer state. That makes every transition easier to understand.

import { useEffect, useReducer } from "react";
import { getQuestions } from "../services/questionsService";
import { Question } from "../shared/types/question.types";
type State = {
  questions: Question[];
  status: "loading" | "error" | "ready" | "active" | "finished";
  error: string | null;
};
type Action =
  | { type: "dataReceived"; payload: Question[] }
  | { type: "dataFailed"; payload: string }
  | { type: "loading" };
const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case "dataReceived":
      return {
        ...state,
        questions: action.payload,
        status: "ready",
        error: null
      };
    case "dataFailed":
      return {
        ...state,
        questions: [],
        status: "error",
        error: action.payload
      };
    case "loading":
      return {
        ...state,
        status: "loading"
      };
    default:
      return state;
  }
};
export const useQuestions = (query: string) => {
  const initialState: State = {
    questions: [],
    status: "loading",
    error: null
  };
  const [state, dispatch] = useReducer(reducer, initialState);
  useEffect(() => {
    if (!query) return;
    const controller = new AbortController();
    const fetchQuestions = async () => {
      dispatch({ type: "loading" });
      try {
        const data = await getQuestions(controller.signal);
        dispatch({ type: "dataReceived", payload: data });
      } catch (err) {
        if (err instanceof Error && err.name === "AbortError") {
          return;
        }
        dispatch({
          type: "dataFailed",
          payload: err instanceof Error ? err.message : "Something went wrong"
        });
      }
    };
    fetchQuestions();
    return () => {
      controller.abort();
    };
  }, [query]);
  return state;
};

Why useReducer works well here

The reducer gives the data flow a clear structure:

  • When the request starts, we dispatch loading.
  • When the data comes back successfully, we dispatch dataReceived.
  • If something fails, we dispatch dataFailed.

[embed]GitHub - React-tuts/reducer-quiz Contribute to React-tuts/reducer-quiz development by creating an account on GitHub.github.com

[embed]List: React - TS | Curated by quizzesforyou.com | Medium React - TS · 9 stories on Mediummedium.com


메타데이터
post_id
6c31ce7ef630
slug
react-10-typescript-usereducer-6c31ce7ef630
url
https://medium.com/@quizzesforyou/react-10-typescript-usereducer-6c31ce7ef630
canonical_url
https://medium.com/@quizzesforyou/react-10-typescript-usereducer-6c31ce7ef630
author_url
https://medium.com/@quizzesforyou
status
ok
fetched_at
2026-06-24 11:06:28