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:
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:
How to utilize custom hooks for business logic handling.
How to validate the data we send and receive.
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;
}>;
};
}
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;
}
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
:
// src/modules/Home/application/useQuiz.ts
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 };
}
Let’s break the hook down:
First, we load the data from the repository using
useSuspenseQuery
. ThefindCountries
returns the result of thequeryOptions
method from TanStack Query, so we can spread it intouseSuspenseQuery
.NOTE: Dive deeper into the repository pattern described in the data layer article.
Second, we map the data from the repository utilizing the select method. The method is also provided by the TanStack Query.
Then we select one random country from the array of mapped countries.
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;
},
// ...
});
// ...
}
Let’s go through the process step by step:
First, we parse the data from the repository using Zod’s safeParse method.
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 beforeselect
, ensuring only valid data reaches the mapping step.
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
};
}
Pretty a lot, 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
empty string as the default.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:
Last but not least, we define submission logic:
Create a DTO object that we’ll save in localStorage.
Parse and validate the DTO.
If validation isn’t successful, we take the first error and set it into
submissionError
state.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 };
}
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:
Reset the
answer
form field.Set a new random country.
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>;
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
});
});
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.
With that said, we’ve finished testing the application layer. And now, as in the previous articles, I want you to practice a bit.
Homework
Create a new useHistoryList
hook that will correspond to HistoryList
view you had to build as the practice task in the view layer article. The hook should:
Fetch the history from localStorage using the
getHistory
method of theHistoryRepository
.Validate the history array.
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:
You’ve learned where and how to organize the business logic.
Together, we’ve considered the most common tasks for the application layer: form handling, data loading, mapping, and validation.
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?
TkDodo wrote React Query Selectors, Supercharged, in case you want to delve really deep into results transformation in TanStack Query.
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!
📰 Read me on the platform of your choice: Substack, DEV.to
See ya soon 👋