DEV Community

Cover image for React MVVM Architecture with TanStack Router: Application Layer
Olexandr
Olexandr

Posted on • Originally published at techwood.substack.com

React MVVM Architecture with TanStack Router: Application Layer

When we build complex, feature-rich React applications, we tend to break the codebase into smaller components. But the problem is that as our application grows, so do our components.

At some point, you realize it’s impossible to read, maintain, or test the enormous Frankenstein that blends UI, business, and data access logic. A lot of the components like this lead to bugs, slowing down feature delivery and new devs’ onboarding difficulties.

In the previous articles, we’ve already moved UI and data parts to their appropriate layers:

Today, we study the application layer, which handles the business logic within the module:

Diagram of React MVVM architecture layers: Data (Repositories), Model (DTO, Module Type, Prop types), Application (highlighted) (Hooks), View (Widgets).

TIP: If you haven’t read my MVVM Introduction yet, I highly recommend doing that first. It’ll help you understand where the application layer fits in the bigger picture.

In this article, you’ll learn:

  1. How to utilize custom hooks for business logic handling.
  2. How to validate the data we send and receive.
  3. How to handle forms using TanStack Form. I introduced the library in the view layer article.

Before we begin, I want to remind, that in this MVVM series, we’re building a country quiz app. The app prompts the user with the flag, and the user has to guess the country. Once they entered and sent the answer, we save it in localStorage. Then we display the answer history in the left sidebar. You can find the source code on GitHub and the Demo on CodeSandbox.

Random country to guess

In the data layer article, we fetched all the available countries from the REST Countries service using the findCountries method of HomeRepository. The response object of the method looks like this:

type CountryPayload = {
  flags: {
    png: string;
    svg: string;
    alt: string;
  };
  name: {
    common: string;
    official: string;
    nativeName: Record<string, {
        official: string;
        common: string;
    }>;
  };
}
Enter fullscreen mode Exit fullscreen mode

Obviously, we don’t need all the fields in our simple app. It’ll be enough to have the next object:

type Country = {
  flag: string;
  name: string;
}
Enter fullscreen mode Exit fullscreen mode

To get such an object, we need to map the data coming from the findCountries method. Let’s create the application layer for the Home module and add a custom hook called useQuiz:

import { useSuspenseQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { homeRepositoryFactory } from "../data";

export const useQuiz = () => {
  const homeRepository = homeRepositoryFactory();
  const historyRepository = historyRepositoryFactory();

  // 1. Load the data
  const {
    data: countries,
    isLoading,
    error,
  } = useSuspenseQuery({
    ...homeRepository.findCountries(),
    // 2. Map the data to the desired structure
    select: (countries) =>
      countries.data.map((country) => ({
        flag: country.flags.png,
        name: country.name.common,
      })),
  });

  // 3. Select one random country
  // This may occasionally give the same country again. You can customize the randomizer to exclude the current one if needed.
  const randomCountry = useMemo(
    // TODO: Add empty-array guard, just in case the rest countries provider ever returns empty array
    () => countries[Math.floor(Math.random() * countries.length)],
    [countries]
  );

  // 4. State with one random country. We'll use it when we submit the form
  const [country, setCountry] = useState(randomCountry);

  // We rely on TypeScript to infer the return type of useQuiz automatically.
  return { country, isLoading, error };
}
Enter fullscreen mode Exit fullscreen mode

Let’s break the hook down:

  1. First, we load the data from the repository using useSuspenseQuery. The findCountries returns the result of the queryOptions method from TanStack Query, so we can spread it into useSuspenseQuery. > NOTE: Dive deeper into the repository pattern described in the data layer article.
  2. Second, we map the data from the repository utilizing the select method. The method is also provided by the TanStack Query.
  3. Then we select one random country from the array of mapped countries.
  4. Last but not least, we save the random country in the state, so we can change the country later when the user presses the “Next country” button.

We’ve mapped the complex object into one that is much easier to work with. But imagine the third-party API provider changed the response signature. Now it has flagImages key instead of flags. If we try to map our data, we’ll get annoying “TypeError: Cannot read properties of undefined”

Wrong data shall not pass

To avoid situations above, I’ve introduced Zod in the model layer article. Before we map the data, we should validate it in the queryFn method:

import { countriesPayload } from "../model";

export const useQuiz = () => {
  const homeRepository = homeRepositoryFactory();
  const historyRepository = historyRepositoryFactory();

  const {
    data: countries,
    isLoading,
    error,
  } = useSuspenseQuery({
    ...homeRepository.findCountries(),
    queryFn: async (context) => {
      const queryFn = homeRepository.findCountries().queryFn;

      if (!queryFn) {
        throw new Error(
          "Unexpected error happened during loading the countries. Please retry after 15 minutes."
        );
      }

      const countries = await queryFn(context);

      // Parse the data from the repository
      const payload = countriesPayload.safeParse(countries.data);

      // In Suspense mode, thrown errors are caught by your error boundary
      if (!payload.success) {
        throw new Error(
          "We've received an incorrect data from our countries provider. We're already fixing this error, please retry after 15 minutes.",
          { cause: payload.error }
        );
      }

      return countries;
    },
    // ...
  });
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Let’s go through the process step by step:

  1. First, we parse the data from the repository using Zod’s safeParse method.
  2. Next, if the actual data and its Zod signature don’t match, we immediately throw an error. Then your error boundary catches it.

NOTE: We put validation in queryFn because it runs before select, ensuring only valid data reaches the mapping step.

Diagram of data flow: Data Layer Repositories → Application Layer (queryFn, select) → View Layer Widgets.

Data from the repository is mapped and validated! Now it’s time to get the user’s answer, validate it, and save it.

Form submission

As I stated previously, to handle the form, we use TanStack Form. Inside the application layer, we must initialize the form using the useForm hook:

import { createHistoryRecordDTO, type CreateHistoryRecordDTO } from "@/modules/History/model";
import { historyRepositoryFactory } from "@/modules/History/data";
import {useForm} from '@tanstack/react-form'
import z from "zod";

export const useQuiz = () => {
  const historyRepository = historyRepositoryFactory();

  //...
  // 1. States with the result and error to check and display them in the view
  const [result, setResult] = useState<CreateHistoryRecordDTO>();
  const [submissionError, setSubmissionError] = useState("");

  // 2. Form instance initialization, answer is one default empty string value
  const form = useForm({
    defaultValues: { answer: "" },
    // 3. Specify form validator
    validators: {
      onChange: ({ value }) => ({
        fields: {
          answer: !value.answer ? "You cannot submit empty answer" : undefined,
        },
      }),
    },
    // 4. Submission logic
    onSubmit({ value }) {
      // a. Create a DTO object
      const dto = {
        flagImage: country.flag,
        countryName: country.name,
        userAnswer: value.answer,
      };

      // b. Parse DTO
      const historyDto = createHistoryRecordDTO.safeParse(dto);

      if (!historyDto.success) {
        // c. https://zod.dev/error-formatting#zflattenerror
        const error = z.flattenError(
          historyDto.error as unknown as z.ZodError<CreateHistoryRecordDTO>
        );

        // d. Retrieve the first error
        const formError = error.formErrors[0];
        const firstFieldError = Object.values(error.fieldErrors)[0][0];

        // e. Set the error
        setSubmissionError(formError || firstFieldError);

        return;
      }

      // f. Set the result
      setResult(dto);

      // g. Save the result
      // TODO: saving to localStorage can fail, add try/catch if you need it
      historyRepository.saveAnswer(historyDto.data);
    },
  });
  //...

  return { country, isLoading, form, result, error, submissionError };
}
Enter fullscreen mode Exit fullscreen mode

Pretty much, I know, but nothing difficult here:

First, we create a local state with the submission result and error so the view can display them.

Next, we initialize the new form instance with an answer as the default empty string.

Third, we specify a form validator. This one simply checks whether the submitted answer isn’t empty. Form validator has nothing to do with Zod’s one. Let’s quickly compare them to understand the difference:

TanStack Form Zod
What it validates Only the user’s input (answer in our case) The whole DTO, even fields, like flagImage or countryName, that the user didn’t input but are critical for business logic
How errors are surfaced We conveniently receive the error in the view through TanStack Form itself We must store and provide the validation error to the view layer ourselves (we do it through the submissionError)

Last but not least, we define submission logic:

  1. Create a DTO object that we’ll save in localStorage.
  2. Parse and validate the DTO.
  3. If validation isn’t successful, we take the first error and set it into submissionError state.
  4. If validation is successful, we set the result into the state and save it in localStorage through the HistoryRepository.

Triggering the form submission

Okay, we’ve defined the submission logic, but how do we trigger it? A piece of cake:

import { useCallback, useMemo, useState } from "react";

export const useQuiz = () => {
  //...

  const onSubmit = useCallback(() => {
    if (!result) {
      form.handleSubmit();
    } else {
      form.resetField("answer");
      setCountry(randomCountry);
      setResult(undefined);
    }
  }, [form, result, randomCountry]);

  //...

  return { country, isLoading, form, result, error, onSubmit, submissionError };
}
Enter fullscreen mode Exit fullscreen mode

As usual, let’s deconstruct what’s going on:

If there’s no result yet, we submit the form.

If there’s a result, we:

  1. Reset the answer form field.
  2. Set a new random country.
  3. Set the result to undefined to be able to submit the form again.

And that’s it! We’ve successfully created the custom useQuiz hook that handles all the business logic required to make the quiz work.

But the job is not fully done yet. Remember, in the view layer article, we defined a QuizProps for the Quiz widget? Now it’s time to connect the useQuiz with the QuizProps like this:

import type { useQuiz } from "../application/useQuiz";

export type QuizProps = ReturnType<typeof useQuiz>;
Enter fullscreen mode Exit fullscreen mode

This applies the trick we’ve discussed in the model layer article in practice.

With the hook created and props tweaked, it’s time to ensure the hook works as expected.

Testing

NOTE: As always, I’m using Vitest and React Testing Library to write unit tests.

Tests for useQuiz look like this:

import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useQuiz } from "../useQuiz";

// Mock dependencies
vi.mock("@/modules/History/data", () => ({
  historyRepositoryFactory: vi.fn(() => ({
    saveAnswer: vi.fn(),
  })),
}));

vi.mock("../../data", () => ({
  homeRepositoryFactory: vi.fn(() => ({
    findCountries: vi.fn(() => ({
      queryKey: ["countries"],
      queryFn: vi.fn(),
    })),
  })),
}));

vi.mock("@/modules/History/model", () => ({
  createHistoryRecordDTO: {
    safeParse: vi.fn(),
  },
}));

vi.mock("../../model", () => ({
  countriesPayload: {
    safeParse: vi.fn(),
  },
}));

vi.mock("@tanstack/react-query", () => ({
  useSuspenseQuery: vi.fn(),
}));

vi.mock("@tanstack/react-form", () => ({
  useForm: vi.fn(),
}));

describe("useQuiz Hook", () => {
  const mockRawCountriesResponse = {
    data: [
      {
        flags: { png: "https://example.com/fr.png" },
        name: { common: "France" },
      },
      {
        flags: { png: "https://example.com/de.png" },
        name: { common: "Germany" },
      },
    ],
  };

  const mockTransformedCountries = [
    { flag: "https://example.com/fr.png", name: "France" },
    { flag: "https://example.com/de.png", name: "Germany" },
  ];

  beforeEach(async () => {
    // Mock react-query - now returns already transformed data from queryFn
    const reactQuery = await import("@tanstack/react-query");
    vi.mocked(reactQuery.useSuspenseQuery).mockReturnValue({
      data: mockTransformedCountries,
      isLoading: false,
      error: null,
    } as any);

    // Mock react-form
    const reactForm = await import("@tanstack/react-form");
    vi.mocked(reactForm.useForm).mockReturnValue({
      Field: vi.fn(),
      handleSubmit: vi.fn(),
      resetField: vi.fn(),
      setFieldValue: vi.fn(),
      useField: vi.fn(() => ({
        state: { value: "", meta: { errors: [] } },
      })),
    } as any);

    // Mock model parsers - these are now used in the repository's queryFn
    const model = await import("../../model");
    vi.mocked(model.countriesPayload.safeParse).mockReturnValue({
      success: true,
      data: mockRawCountriesResponse.data,
    } as any);

    const historyModel = await import("@/modules/History/model");
    vi.mocked(historyModel.createHistoryRecordDTO.safeParse).mockReturnValue({
      success: true,
      data: {
        flagImage: "https://example.com/fr.png",
        countryName: "France",
        userAnswer: "france",
      },
    } as any);

    vi.clearAllMocks();
  });

  describe("VALID SCENARIOS", () => {
    it("should initialize with default values and random country", () => {
      const { result } = renderHook(() => useQuiz());

      expect(result.current.country).toBeDefined();
      expect(result.current.isLoading).toBe(false);
      expect(result.current.error).toBeNull();
      expect(result.current.submissionError).toBe("");
    });

    // Find the rest of the tests here: https://github.com/ole-techwood/articles/tree/mvvm/src/modules/Home/application/tests
  });

  describe("BOUNDARY CONDITIONS", () => {
    it("should handle single country in dataset", async () => {
      const singleCountry = [
        { flag: "https://example.com/fr.png", name: "France" },
      ];

      const reactQuery = await import("@tanstack/react-query");
      vi.mocked(reactQuery.useSuspenseQuery).mockReturnValue({
        data: singleCountry,
        isLoading: false,
        error: null,
      } as any);

      const { result } = renderHook(() => useQuiz());

      expect(result.current.country).toEqual(singleCountry[0]);
    });

    // Find the rest of the tests here: https://github.com/ole-techwood/articles/tree/mvvm/src/modules/Home/application/tests
  });

  describe("EDGE CASES", () => {
    it("should maintain state consistency during re-renders", () => {
      const { result, rerender } = renderHook(() => useQuiz());

      const initialCountry = result.current.country;
      rerender();

      expect(result.current.country).toBeDefined();
      expect(result.current.country).toEqual(initialCountry);
    });

    // Find the rest of the tests here: https://github.com/ole-techwood/articles/tree/mvvm/src/modules/Home/application/tests
  });
});
Enter fullscreen mode Exit fullscreen mode

Yes, business logic remains the hardest part of the app to test. You have to mock a lot, and you have to think about many cases. But, hey, at least now you test your business logic in isolation from other layers of the app. This approach makes your mocks more predictable and easier.

NOTE: Keep in mind that I’ve tested only basic, valid, and edge conditions to show you the principle of testing the application layer. You might need to expand the test coverage in the production-grade systems.

Homework

Create a new useHistoryList hook that will correspond to the HistoryList view you had to build as the practice task in the view layer article. The hook should:

  1. Fetch the history from localStorage using the getHistory method of the HistoryRepository.
  2. Validate the history array.
  3. Return the history or the error.

As usual, you can find the ready assignment in the source code.

Conclusion

You nailed it! 🚀

By this point, you’ve mastered business logic handling:

  1. You’ve learned where and how to organize the business logic.
  2. Together, we’ve considered the most common tasks for the application layer: form handling, data loading, mapping, and validation.
  3. At the end, we’ve tested the business logic to make sure it works as expected.

With such a strong foundation, you’re now capable of implementing business logic of any complexity in your projects. All the best with that!

Further reading

You’re almost at the end of our MVVM journey! Read the last article of the series:

Or you can go and revisit the previous ones:

Want more?


Creating content like this is not an easy ride, so I hope you’ll hit that like button and drop a comment. As usual, constructive feedback is always welcome!

🔗 Follow me on LinkedIn

📰 Read me on the platform of your choice: Substack, DEV.to

See ya soon 👋

Top comments (0)