DEV Community

Khang Tran
Khang Tran

Posted on • Updated on

A boilerplate for a React application using TypeScript and Vite, following the MVVM

First touch

Hi team, hope you are doing well!

This is my first time here, and in this article, I want to walk you through architecting a front-end application using React/Vite with the MVVM architecture. It will not be a traditional MVVM architecture, as I have updated and optimized it to suit the React structure.

I spent 3 days on this architecture, and in this article, we will just answer the WHY questions. I will not go deep into the HOW (to install) questions because you can easily find that information on the internet.

I think this will be helpful for beginners.

Letโ€™s get started!

What is MVVM?

MVVM is a design pattern that separates concerns by dividing the application into three components: Models (data handling), Views (UI), and ViewModels (interaction logic). This separation enhances code maintainability and scalability, making it easier to manage large codebases.

What is vite?

Vite is a modern build tool that provides a lightning-fast development experience. With Vite, you get instant server startup, optimized builds, and seamless integration with TypeScript and React. Itโ€™s the perfect companion for developing modern web applications. Vite docs

Key Features

Project structure

Project Structure

Explanation of the folder structure and organization.

๐Ÿ’„ assets

Purpose: Store all images and static assets.
Guideline: Place image files and other static assets in this directory.

๐Ÿ”ง config
Purpose: Store configuration files.
Guideline: Include environment settings, theme configurations, authentication settings, etc., in this directory.

๐ŸŒ core

client: Contains Axios configuration.
models: Defines models and their mappings.
repositories: Defines abstract classes for repositories, acting as contracts between presentation and infrastructure layers.
repositoryImpl: Implements the repository classes.
useCases: Defines the moduleโ€™s behavior (e.g., UserUseCase -> getAll, getById).

โœ๏ธ enum

Purpose: Store global constants.
Guideline: Place enumerations and global constants in this directory.

๐Ÿ“„ presentations

components: Contains shared components and their stories.
hooks: Contains shared custom hooks.
layout: Defines the layout of the application (e.g., useAuth, useLocalStorage).
pages: Contains application pages.
routers: Defines application routes, utilizing dynamic import to split chunks at build time.
viewModels: Defines the viewModel layer as a hook to manage business logic and provide data for the view layer.

๐Ÿšš providers

Purpose: Store global providers.
Guideline: Include all global providers in this directory.

๐Ÿ”’๏ธ schemas

Purpose: Store schema validations.
Guideline: Include schema validations for all input fields in this directory.

๐Ÿ“ types

Purpose: Store types and interfaces.
Guideline: Include types and interfaces such as APIRequest, APIResponse, etc., in this directory.

๐Ÿ”จ utils

Purpose: Store utility functions.
Guideline: Place all utility functions in this directory. Include the utility functions in the utils folder (e.g: helpers, conversion factors)

MVVM Implementation

In this section, I will discuss the implementation of the MVVM architecture, from the View of the API calling service. Letโ€™s pick the Login feature for an example (the example repository will be placed at the end of this article or here)

To implement the Login feature you have to build the View layer ๐Ÿ˜…

export default function LoginPage() {
    const { onLogin } = useAuthViewModel();

    return (
        <Container maxWidth="xs">
            <CssBaseline />
            <Box
                sx={{
                    pt: 8,
                    display: "flex",
                    flexDirection: "column",
                    alignItems: "center",
                }}
            >
                <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
                    <LockOutlinedIcon />
                </Avatar>
                <Typography component="h1" variant="h5">
                    Login
                </Typography>
                <Formik
                    initialValues={{ email: "", password: "" }}
                    validationSchema={loginValidationSchema}
                    onSubmit={onLogin}
                >
                    {({ isSubmitting }) => (
                        <Form style={{ width: "100%", marginTop: 1 }}>
                            <Field
                                component={CustomTextField}
                                id="email"
                                label="Email Address"
                                name="email"
                                autoComplete="email"
                                autoFocus
                                required
                            />
                            <Field
                                component={CustomTextField}
                                name="password"
                                label="Password"
                                type="password"
                                id="password"
                                autoComplete="current-password"
                                required
                            />
                            <FormControlLabel
                                control={
                                    <Field
                                        as={Checkbox}
                                        name="remember"
                                        color="primary"
                                    />
                                }
                                label="Remember me"
                            />
                            <CustomButton
                                label="Login"
                                loading={isSubmitting}
                            />
                            <Grid container>
                                <Grid item xs>
                                    <Link href="#" variant="body2">
                                        Forgot password?
                                    </Link>
                                </Grid>
                                <Grid item>
                                    <Link
                                        // href={AuthRoute.register}
                                        variant="body2"
                                    >
                                        {"Don't have an account? Sign Up"}
                                    </Link>
                                </Grid>
                            </Grid>
                        </Form>
                    )}
                </Formik>
            </Box>
        </Container>
    );
}
Enter fullscreen mode Exit fullscreen mode

Look at the useAuthViewModel hooks, and why we need the ViewModel layer.

In this case, the LoginPage (View layer) only handles things related to the user interface. It is used to render data from the ViewModel.

Letโ€™s move to the ViewModel layer.

export const useAuthViewModel = () => {
    const { execute: _login, loading: isLogging } = useFetching<AuthModel>(
        authUseCase.login
    );
    const { execute: _logout } = useFetching<void>(authUseCase.logout);
    const { showSnackbar } = useSnackbar();
    const { setValue } = useLocalStorage(AUTH_KEY.token);
    const { getCurrentUser } = useAuth();
    const navigator = useNavigate();

    const onLogin = async (payload: LoginPayload) => {
        const { data, error } = await _login<LoginPayload>(payload);
        if (data) {
            setValue({ ...data });
            showSnackbar("Login successfully", "success");
            getCurrentUser();
            window.location.replace(import.meta.env.VITE_APP_URL);
        }
        if (error) {
            showSnackbar(error.message, "error");
        }
    };

    const logout = async () => {
        setValue(null);
        await _logout<LoginPayload>();
        navigator(AUTH_ROUTES.login);
    };

    return {
        isLogging,
        onLogin,
        logout,
    };
};
Enter fullscreen mode Exit fullscreen mode

In the ViewModel layer, we have an onLogin function. This function is invoked when the user clicks the login button. It encapsulates the entire login logic, managing both success and error scenarios. When the login is successful, it stores the user's token, displays a success message, and redirects the user to the main application. In the event of an error, it shows an appropriate error message to the user. This separation of concerns ensures that the View layer focuses solely on the user interface, while the ViewModel layer handles the business logic and state management.

And take a look at the authUseCase.login, and we explored more of the use-case class.

In this class, we declare the methods of the module such as Login, Register, and the mapping factory (if you need to transform the results from the APIs), see the example below:

export class AuthUseCase {
    constructor(private readonly authRepository: AuthRepository) {
        classUtils.autoBind(this);
    }
    async login(payload: LoginPayload): Promise<AuthModel> {
        const res = await this.authRepository.login(payload);
        return AuthModel.mapper(res);
    }
}

/* */ 

export class AuthModel {
    accessToken: string;
    refreshToken: string;

    static mapper(data: unknown): AuthModel {
        const authModel = new AuthModel();
        authModel.accessToken = data['at'] as string;
        authModel.refreshToken = data['rt'] as string;
        return authModel;
    }
}
Enter fullscreen mode Exit fullscreen mode

The AuthUseCase handles the application's core business logic, defined and implemented through use case classes, each corresponding to specific business actions. It encapsulates the system's business logic for other system layers.

This outermost layer provides infrastructure and services for other layers, including code for interacting with databases, web services, and external systems. For example, a AuthRepository class to send and receive requests to the server.

export abstract class AuthRepository {
    abstract login(payload: LoginPayload): Promise<LoginResponse>;
}

/* */

export class AuthRepositoryImpl implements AuthRepository {
    _client: APIClientImpl;

    private readonly _prefix = "auth";

    constructor() {
        this._client = new APIClientImpl(this._prefix);
    }

    async login(payload: LoginPayload): Promise<LoginResponse> {
        return await this._client.post<LoginResponse, LoginPayload>(
            "/login",
            payload
        );
    }
}

Enter fullscreen mode Exit fullscreen mode

The AuthRepository serves as a contract layer between the use-case and the API client. For API client configuration, refer to this link.

Following the example above, we separate the Model layer into smaller modules: Model - UseCase - Repository. The purpose of this is that each layer is responsible for a specific group of tasks within a module. We make it scalable and maintainable, even as it gets larger.

Why Use Storybook?

Storybook offers a multitude of benefits for React developers:

  • Component Isolation: Develop and test components in isolation, without the complexities of the entire application. This fosters better component reusability and maintainability.
  • Rapid Development: Iterate quickly on component designs and interactions without affecting the main application.
  • Living Styleguide: Create a visual styleguide for your design system, making it easy to share and maintain design consistency across the project.
  • Improved Collaboration: Share components and design decisions with other team members through Storybook.
  • Automated Testing: Integrate testing frameworks to ensure component quality and prevent regressions.
  • Enhanced Developer Experience: Streamline development workflows by providing a dedicated environment for component development.
import CustomButton from "./CustomButton";

export default {
    title: "Components/CustomButton",
    component: CustomButton,
    argTypes: {
        label: { control: "text" },
        loading: { control: "boolean" },
        onClick: { action: "clicked" },
    },
};

const Template = (args) => <CustomButton {...args} />;

export const Primary = Template.bind({});
Primary.args = {
    label: "Primary Button",
    loading: false,
};

export const Loading = Template.bind({});
Loading.args = {
    label: "Loading Button",
    loading: true,
};

export const Disabled = Template.bind({});
Disabled.args = {
    label: "Disabled Button",
    disabled: true,
};

/* */

const CustomButton: React.FC<CustomButtonProps> = ({
    label,
    loading = false,
    ...props
}) => {
    return (
        <Button
            type="submit"
            fullWidth
            variant="contained"
            color="primary"
            sx={{ mt: 3, mb: 2 }}
            disabled={loading}
            {...props}
            disableElevation
            onLoad={() => loading}
        >
            {label}
        </Button>
    );
};
Enter fullscreen mode Exit fullscreen mode

Storybook demo

Storybook demo

A story shows how a UI component should look on your browser. With Storybook, you can write multiple stories based on every change you want to make to your component. This means that rather than changing elements in the DOM, Storybook updates the information a user sees based on the stories youโ€™ve created.

Example code

You can find the example source code in this Repository, and donโ€™t forget to start the repository if you like it. Thanks!

Final works

In this article, I've gone over how to apply MVVM to the ReactJs project, It has many mistakes because it's my first post, so if you are a beginner hope you will get some information that is helpful for your career and if you are an expert hope you leave a comment for me to improve and make it better in the future. Thank you all.

Top comments (1)