DEV Community

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

Posted on • Originally published at techwood.substack.com

React MVVM Architecture with TanStack Router: View Layer

A clear separation between UI and business logic is absolutely crucial. It makes testing and maintaining your React components much easier. This is something I scrambled with a lot in the past. I would even avoid writing unit tests, because it was so hard to mock everything in some enormous component that included JSX, hooks, data fetching, etc., all in one.

That’s why I started delving into the clean architecture in React. After years of research, trial and error, I finally found the approach that helps me to keep my components simple, yet rich in UI and features.

Today, we’re talking about the view layer of this five-layer MVVM implementation:

Diagram of MVVM architecture layers: Orchestrator, Data Layer (Repositories), Model layer (DTO & Payload, Module Type, Prop types), Application Layer (Hooks), and View Layer (highlighted) (Widgets).

In this article, you’ll learn:

  1. How and where to keep the layout for the whole app in TanStack Router.
  2. How and why keep your components clean.
  3. How easy it is to test clean React components.

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.

Shape of the whole app

TanStack Router recommends utilizing its file-based routing system. That’s why when we bootstrapped our app in the data layer article, we got the routes folder with two files:

  • __root.tsx - wrapper for all routes in our app.
  • index.tsx - main route in our app. It stands for the / path.

At the moment, let’s focus our attention on __root.tsx, because that’s where we’ll keep the layout of the app:

import { Grid } from "@mui/material";
import { Outlet, createRootRoute } from "@tanstack/react-router";

export const Route = createRootRoute({
  component: () => (
    <Grid container sx={{ height: "100vh" }}>
      <Grid
        size={3}
        sx={{
          height: "100%",
          backgroundColor: "ButtonShadow",
          overflowY: "scroll",
        }}
      >
        {/* Put here the history list once it's implemented */}
      </Grid>
      <Grid size={9}>
        <Outlet />
      </Grid>
    </Grid>
  ),
});
Enter fullscreen mode Exit fullscreen mode

Let’s break it down:

  1. First, we create a root route. The official documentation explains it: “The createRootRoute function returns a new root route instance”. The difference between the root route and the index route is that the root route doesn’t have a path, like the index route does (/ path). Root route is used to create a route tree and store the layout of the app.

  2. Second, we define a structure of the layout using the Grid component. This component comes from the MUI library. Using it is an optional step. You can choose a different components library or skip any libraries entirely. I’ll continue utilizing MUI in all the next tutorials, because it’s just easier and quicker to develop UI with its wide range of components. With the Grid component, we defined the next layout:
    Wireframe layout showing a left sidebar labeled “Sidebar with the history” and main area labeled “Main content with quiz.”
    Humble, but more than enough for our simple app.

  3. The final touch is the Outlet component, which is provided by TanStack Router. The Outlet renders the next potentially matching child route. If the user has entered / in the browser’s search bar, the Outlet will render the UI matching the / path.

Good, we have the layout. Let’s fill it with the content now.

Let the users guess the country

Now that we’ve defined the layout, it’s time to focus on the Quiz widget that the user interacts with:

import {
  Button,
  Card,
  CardActions,
  CardContent,
  CardMedia,
} from "@mui/material";
import type { QuizProps } from "../model";
import { QuizForm } from "./QuizForm";
import { QuizResult } from "./QuizResult";

export const Quiz: React.FC<QuizProps> = ({
  country,
  form,
  result,
  onSubmit,
}) => {
  return (
    <Card sx={{ width: 320, margin: "0 auto" }}>
      <CardMedia
        sx={{ height: 198 }}
        image={country.flag}
        title="Country flag"
      />
      <CardContent sx={(theme) => ({ padding: theme.spacing(2, 1) })}>
        {!result ? <QuizForm form={form} /> : <QuizResult result={result} />}
      </CardContent>
      <CardActions sx={{ display: "flex", justifyContent: "end" }}>
        <Button size="small" variant="contained" onClick={onSubmit}>
          {!result ? "Submit answer" : "Next country"}
        </Button>
      </CardActions>
    </Card>
  );
};
Enter fullscreen mode Exit fullscreen mode

NOTE: What’s the widget? - It is an individual component that renders the UI and responds to events. Why do I call it a widget? - It’s a Flutter-inspired name to call a React component, which already combines a couple of other smaller components. For example, Quiz widget blends Card, Button, CardContent, and so on:
Diagram showing a quiz widget with China’s flag, an input field, and a submit button labeled as “Widget,” with each element (flag, input, button) marked as a separate “Components.”

Let’s break the Quiz widget step by step, just like we did with the layout:

We start by creating a functional React component typed with QuizProps, which accepts the following props:

  • country - the object of a randomly selected country to guess.
  • form - TanStack Form’s instance. TanStack Form is a headless form state manager. It includes submission, validation, composition, and other great staff. We touch on some UI aspects of the library later in this article. We also discuss form handling in the application layer article. Feel free to use any form management library if you’re not comfy with TanStack Form.
  • result - the result of the user’s attempt to guess the country.
  • onSubmit - a function that’s called when the user clicks the action button. It either submits the answer or triggers loading the next country, if the result is already shown.

For now, QuizProps can look like this:

import type { CreateHistoryRecordDTO } from "@/modules/History/model";

export type QuizProps = {
  country: any;
  form: any;
  result?: CreateHistoryRecordDTO;
  onSubmit: VoidFunction;
};
Enter fullscreen mode Exit fullscreen mode

We’ll fix it when we develop the hook to handle quiz logic and apply the props trick from the model layer article.

Next, I use Card MUI’s component and its pieces to create an actual UI:

return (
  <Card sx={{ // ... }}>
    <CardMedia
      // ...
    />
    <CardContent sx={// ...}>
      // ...
    </CardContent>
    <CardActions sx={// ...}>
      // ...
    </CardActions>
  </Card>
  );
Enter fullscreen mode Exit fullscreen mode

Pay attention to this line:

{!result ? <QuizForm form={form} /> : <QuizResult result={result} />}
Enter fullscreen mode Exit fullscreen mode

Here we have 2 additional components: QuizForm and QuizResult. I created them because of 2 reasons:

  1. Make the Quiz widget smaller by moving UI with different responsibilities to different widgets.
  2. Fulfill the Single Responsibility principle of SOLID.

Let’s take a look at each widget. 👇

Answering form

Since the form is what the user sees first, let’s begin with QuizForm, the input widget responsible for capturing the answer:

import { TextField } from "@mui/material";
import type { QuizFormProps } from "../model";

export const QuizForm: React.FC<QuizFormProps> = ({ form }) => {
  return (
    <form>
      <form.Field name="answer">
        {(field) => (
          <TextField
            label="Your answer"
            variant="outlined"
            sx={{ width: "100%" }}
            error={!field.state.meta.isValid}
            helperText={field.state.meta.errors[0]}
            value={field.state.value}
            onChange={(event) => field.handleChange(event.target.value)}
          />
        )}
      </form.Field>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

Again, it’s a stateless, logic-free functional component which expects to receive 1 property: form. This form instance is the same one passed into the Quiz widget, so the QuizFormProps can look like this:

export type QuizFormProps = Pick<QuizProps, "form">;
Enter fullscreen mode Exit fullscreen mode

The next piece of code might be confusing to those unfamiliar with TanStack Form:

//...
<form>
  <form.Field name="answer">
//...
Enter fullscreen mode Exit fullscreen mode

For JSX, form is just a variable in props (not the HTML tag), so form.Field works just like any namespace object. The outer <form> is a native HTML tag. JSX automatically distinguishes them, so no linter errors.

TanStack Form is a headless library, meaning we can integrate any input into it. This is what we did with TextField from MUI:

{(field) => (
  <TextField
    label="Your answer"
    variant="outlined"
    sx={{ width: "100%" }}
    error={!field.state.meta.isValid}
    helperText={field.state.meta.errors[0]}
    value={field.state.value}
    onChange={(event) => field.handleChange(event.target.value)}
  />
)}
Enter fullscreen mode Exit fullscreen mode

A function with a field property exposed by TanStack Form as well. I think the code is self-descriptive enough here, but to grasp all the details, read the official Field documentation.

QuizForm is ready, great!

Right or wrong?

QuizResult displays either a success or failure message based on whether the answer is correct:

import React from "react";
import type { QuizResultProps } from "../model";
import { Alert, Stack, Typography } from "@mui/material";

export const QuizResult: React.FC<QuizResultProps> = ({ result }) => {
  return (
    <Stack>
      {result?.countryName.toLowerCase() ===
      result?.userAnswer.toLowerCase() ? (
        <React.Fragment>
          <Typography component="div" sx={{ mt: 2 }} color="success">
            Brilliant work!
          </Typography>
          <Alert variant="outlined" severity="success">
            {result?.countryName}
          </Alert>
        </React.Fragment>
      ) : (
        <React.Fragment>
          <Typography color="warning">
            No worries. Learning is a process!
          </Typography>
          <Alert variant="outlined" severity="warning">
            {result?.userAnswer}
          </Alert>
          <Typography component="div" sx={{ mt: 2 }} color="success">
            Correct answer
          </Typography>
          <Alert variant="outlined" severity="success">
            {result?.countryName}
          </Alert>
        </React.Fragment>
      )}
    </Stack>
  );
};
Enter fullscreen mode Exit fullscreen mode

It expects to receive a result as a single prop, which is the type of CreateHistoryRecordDTO from the model layer article:

export type QuizResultProps = Pick<QuizProps, "result">;
Enter fullscreen mode Exit fullscreen mode

Then QuizResult checks if the user’s answer is the same as an actual country name:

result?.countryName.toLowerCase() === result?.userAnswer.toLowerCase()
Enter fullscreen mode Exit fullscreen mode

That’s how it decides whether to show “Brilliant work!” success message or “No worries. Learning is a process!” encouragement.

Visual Recap

Here’s a schema to reinforce how widgets are structured in the Home module:

Diagram of a Home module showing the View Layer. Inside it, a Quiz widget contains two components: QuizForm and QuizResult.

Because widgets are regular React components, they can contain other widgets. Of course, for better performance and maintainability, it’s better to keep nesting shallow.

Folders organization

This is how your folder structure should look when you finish creating the view layer of the Home module:

src
├── modules
│   ├── History
│   └── Home
│       ├── data
│       ├── model
│       └── view
│           ├── Quiz.tsx
│           ├── QuizForm.tsx
│           ├── QuizResult.tsx
│           ├── index.ts *- index file for re-exporting*
├── routes
│   ├── __root.tsx
│   └── index.tsx
Enter fullscreen mode Exit fullscreen mode

Previewing the Quiz

Now we can see the Quiz widget by displaying it on the main route. Open src/routes/index.tsx and write the next code:

import { Quiz } from "@/modules/Home/view";
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/")({
  component: Home,
});

// Temporar orchestrating component
function Home() {
  const mockField = {
    state: {
      value: "",
      meta: {
        isValid: true,
        errors: [],
      },
    },
    handleChange: () => {},
  };

  const mockForm = {
    Field: ({ children }: any) => children(mockField),
  } as any;

  const mockProps = {
    country: { flag: "https://flagcdn.com/w320/cn.png", name: "China" },
    form: mockForm,
    result: undefined,
    onSubmit: () => {},
  };

  return <Quiz {...mockProps} />;
}
Enter fullscreen mode Exit fullscreen mode

This isn’t the correct way of doing such things, because:

  1. We should not mix Orchestrator’s code with the route’s one.
  2. Mocking is, obviously, only for testing.

We’ll fix everything step-by-step in the next articles by implementing a real form and submission handling. Also, we’ll randomly pick a country to guess and display the result.

Depending on the properties you provided, you must see the next outcomes:

  • Start of the quiz: Quiz interface showing the flag of China with a text input field labeled “Your answer” and a blue “Submit Answer” button.
  • User entered correct answer: Quiz result showing the flag of China, message “Brilliant work!”, correct answer “China” with a checkmark, and a “Next Country” button.
  • User entered the wrong answer: Quiz screen with Andorra’s flag. User answer: “I don’t know.” Message: “No worries. Learning is a process!” Correct answer: “Andorra.”

Testing

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

Test start of the quiz

First, let’s test the Quiz widget:

import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Quiz } from "../Quiz";
import type { QuizProps } from "../../model";

// Mock Material-UI components to avoid testing library complications
vi.mock("@mui/material", () => ({
  Button: ({ children, onClick, ...props }: any) => (
    <button onClick={onClick} {...props}>
      {children}
    </button>
  ),
  Card: ({ children }: any) => <div>{children}</div>,
  CardActions: ({ children }: any) => <div>{children}</div>,
  CardContent: ({ children }: any) => <div>{children}</div>,
  CardMedia: ({ title, image }: any) => <img alt={title} src={image} />,
}));

// Mock child components
vi.mock("../QuizForm", () => ({
  QuizForm: ({ form }: any) => (
    <div data-testid="quiz-form">
      QuizForm Component - Form State: {JSON.stringify(form)}
    </div>
  ),
}));

vi.mock("../QuizResult", () => ({
  QuizResult: ({ result }: any) => (
    <div data-testid="quiz-result">
      QuizResult Component - Result: {JSON.stringify(result)}
    </div>
  ),
}));

describe("Quiz", () => {
  const mockOnSubmit = vi.fn();

  const mockCountry = {
    flag: "https://example.com/flag.png",
    name: "TestCountry",
  };

  const mockForm = {
    Field: ({ children }: any) =>
      children({ state: { value: "", meta: { isValid: true, errors: [] } } }),
    handleSubmit: vi.fn(),
    resetField: vi.fn(),
  } as any;

  const mockResult = {
    flagImage: "https://example.com/flag.png",
    countryName: "TestCountry",
    userAnswer: "TestAnswer",
  };

  beforeEach(() => {
    vi.clearAllMocks();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("should render quiz form when no result is provided", () => {
    const props: QuizProps = {
      country: mockCountry,
      form: mockForm,
      result: undefined,
      onSubmit: mockOnSubmit,
    };

    render(<Quiz {...props} />);

    expect(screen.getByTestId("quiz-form")).toBeDefined();
    expect(screen.queryByTestId("quiz-result")).toBeNull();
    expect(screen.getByText("Submit answer")).toBeDefined();
  });

  it("should render quiz result when result is provided", () => {
    const props: QuizProps = {
      country: mockCountry,
      form: mockForm,
      result: mockResult,
      onSubmit: mockOnSubmit,
    };

    render(<Quiz {...props} />);

    expect(screen.getByTestId("quiz-result")).toBeDefined();
    expect(screen.queryByTestId("quiz-form")).toBeNull();
    expect(screen.getByText("Next country")).toBeDefined();
  });

    // Find other test cases here: https://github.com/ole-techwood/articles/blob/mvvm/src/modules/Home/view/tests/Quiz.test.tsx
});
Enter fullscreen mode Exit fullscreen mode

Pay attention to how easy it is to test the widget when we don’t have any business logic inside. All we have to mock is the underlying components:

// Mock Material-UI components to avoid testing library complications
vi.mock("@mui/material", () => ({
  Button: ({ children, onClick, ...props }: any) => (
    <button onClick={onClick} {...props}>
      {children}
    </button>
  ),
  Card: ({ children }: any) => <div>{children}</div>,
  CardActions: ({ children }: any) => <div>{children}</div>,
  CardContent: ({ children }: any) => <div>{children}</div>,
  CardMedia: ({ title, image }: any) => <img alt={title} src={image} />,
}));

// Mock child components
vi.mock("../QuizForm", () => ({
  QuizForm: ({ form }: any) => (
    <div data-testid="quiz-form">
      QuizForm Component - Form State: {JSON.stringify(form)}
    </div>
  ),
}));

vi.mock("../QuizResult", () => ({
  QuizResult: ({ result }: any) => (
    <div data-testid="quiz-result">
      QuizResult Component - Result: {JSON.stringify(result)}
    </div>
  ),
}));
Enter fullscreen mode Exit fullscreen mode

We control the widget’s behavior in tests by passing different props,
no need to mock hooks or data fetching logic:

it("should render quiz form when no result is provided", () => {
  const props: QuizProps = {
    country: mockCountry,
    form: mockForm,
    result: undefined,
    onSubmit: mockOnSubmit,
  };

  render(<Quiz {...props} />);

  expect(screen.getByTestId("quiz-form")).toBeDefined();
  expect(screen.queryByTestId("quiz-result")).toBeNull();
  expect(screen.getByText("Submit answer")).toBeDefined();
});

it("should render quiz result when result is provided", () => {
  const props: QuizProps = {
    country: mockCountry,
    form: mockForm,
    result: mockResult,
    onSubmit: mockOnSubmit,
  };

  render(<Quiz {...props} />);

  expect(screen.getByTestId("quiz-result")).toBeDefined();
  expect(screen.queryByTestId("quiz-form")).toBeNull();
  expect(screen.getByText("Next country")).toBeDefined();
});
Enter fullscreen mode Exit fullscreen mode

Test the answering form

This is how we can test QuizForm:

import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { QuizForm } from "../QuizForm";
import type { QuizFormProps } from "../../model";

// Mock Material-UI components
vi.mock("@mui/material", () => ({
  TextField: ({ label, value, onChange, error, helperText, ...props }: any) => (
    <div>
      <label htmlFor="answer-input">{label}</label>
      <input
        id="answer-input"
        value={value}
        onChange={onChange}
        data-error={error}
        {...props}
      />
      {helperText && <span data-testid="helper-text">{helperText}</span>}
    </div>
  ),
}));

describe("QuizForm", () => {
  const mockHandleChange = vi.fn();

  const mockField = {
    state: {
      value: "",
      meta: {
        isValid: true,
        errors: [],
      },
    },
    handleChange: mockHandleChange,
  };

  const mockForm = {
    Field: ({ children }: any) => children(mockField),
  } as any;

  beforeEach(() => {
    vi.clearAllMocks();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("should render form with text input field", () => {
    const props: QuizFormProps = { form: mockForm };

    render(<QuizForm {...props} />);

    expect(screen.getByLabelText("Your answer")).toBeDefined();
    expect(screen.getByRole("textbox")).toBeDefined();
  });

  // Find other test cases here: https://github.com/ole-techwood/articles/blob/mvvm/src/modules/Home/view/tests/QuizForm.test.tsx
});
Enter fullscreen mode Exit fullscreen mode

Again, dead simple, we mock the MUI’s TextField component and then check different conditions by passing different values in the form prop.

Test results

Last but not least - QuizResult tests:

import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { QuizResult } from "../QuizResult";
import type { QuizResultProps } from "../../model";

// Mock Material-UI components
vi.mock("@mui/material", () => ({
  Stack: ({ children }: any) => <div data-testid="stack">{children}</div>,
  Typography: ({ children, color, ...props }: any) => (
    <p data-color={color} {...props}>
      {children}
    </p>
  ),
  Alert: ({ children, severity, variant }: any) => (
    <div data-testid="alert" data-severity={severity} data-variant={variant}>
      {children}
    </div>
  ),
}));

describe("QuizResult", () => {
  const mockCorrectResult = {
    flagImage: "https://example.com/flag.png",
    countryName: "France",
    userAnswer: "france",
  };

  beforeEach(() => {
    vi.clearAllMocks();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("should render success message for correct answer", () => {
    const props: QuizResultProps = { result: mockCorrectResult };

    render(<QuizResult {...props} />);

    expect(screen.getByText("Brilliant work!")).toBeDefined();
    expect(screen.getByText("France")).toBeDefined();

    const successAlert = screen.getByTestId("alert");
    expect(successAlert.getAttribute("data-severity")).toBe("success");
  });

  // Find other test cases here: https://github.com/ole-techwood/articles/blob/mvvm/src/modules/Home/view/tests/QuizResult.test.tsx
});
Enter fullscreen mode Exit fullscreen mode

The testing approach is the same as with the previous two widgets; nothing new here.

And that’s it, we’re done with tests! Now, when you saw the whole process of creating a view layer with widgets, I want to give you a little homework.

Study by practicing

Don’t just grind tutorials. Better implement the new knowledge, practicing right off the bat! That’s why I want you to create a HistoryList widget within the History module of the app.

Requirements:

  • It must be a clean React component with a single history property of the HistoryRecord[] type (we created the type in the model layer article.
  • It must iterate through the history array and list all the answers.
  • It must be integrated into the left sidebar of the layout we created earlier in the article.
  • Add some tests for it.
  • Final result should look kinda like this: Quiz history list showing two entries: wrong answer “I don’t know” for Andorra, and correct answer “China.”

Good luck! Keep in mind, you can always find the ready variant in the source code. But really, do your best and complete the task on your own (remember, AI is your friend, not an enemy! 🙂).

Conclusion

This is it, folks! ✅

Today, we’ve discussed the view layer. In a nutshell, this is the layer our users see and interact with. So it’s essential to keep the layer and its parts clean, maintainable, and covered in tests.

Not just by reading, but by doing you:

  • Learned what the widget is in MVVM architecture.
  • Got to know how to create a layout for the whole app in TanStack Router.
  • Understood how to create and test the widget.

By this time, you have all the knowledge to create robust view layers utilizing MVVM architecture in production-grade systems!

What’s next?

Continue reading the next articles in the MVVM series:

Or go over the previous ones:


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)