Software applications often start out simple and quickly grows big. No matter how small one try to keep it, the code somehow always grows up to be a full-fledged application. In many languages, we follow structural patterns as MVVM and Clean Architecture, which is less common to see in the Wild West Web.
In this article, we will take a look at how a React application can be designed to follow the MVVM architectural design pattern to create a React application that is both scalable and maintainable. We will use TypeScript and functional React components and write custom hooks for the ViewModel and Model. The same code will of course work with React Native with some minor modifications.
If you have some questions while reading the article you have an extensive FAQ at the end of the article.
In This Article
- MVVM Overview
- Application Overview
- View Implementation
- ViewModel Implementation
- Model Implementation
- FAQ
MVVM Overview
If you don't know what MVVM is, you can either google it or read along anyway. You should be able to follow along in the article either way. In short, MVVM is a design pattern that provides a clear separation of concerns, making it easier to develop, test, and maintain software applications.
What MVVM does is to separate an application into Views, ViewModels and Models. Where the Views are responsible for presenting data to the user and capturing user input, the ViewModels act as a mediator between the Views and the Models, and the Models manage the application data.
Some people say architecture matters
Application Overview
The app we will build in this article is the tiny one you can see down here. It's kept small so we can focus on the MVVM architecture. The app consists of a single View listing articles, with an input field for adding a new article with a given name.
The app to be built, a single View to keep it simple
Codewise, the application consists of one React component for the articles View, then a few hooks for the ViewController, ViewModel and Model. Below is a architectural diagram of the code we will go through. Disregard the ViewController for now, which isn't an official part of MVVM, we will discuss that one later.
Overview of the React components for the application in this article
A single View is of course not enough for a useful application. If the example would be larger, each View would have its own ViewController, as depicted in the diagram below. All ViewControllers that work against the same Model would use a common ViewModel.
What the application would look like with more views
When the application grows even more it can include more Models and ViewModels. I think you get that. Anyhow, let us start looking at the implementation for the app.
View Implementation
A View in the application can be any screen or visual component on the screen. If the site to be built is a blog platform, we could have screens for listing articles, viewing a single article, and for writing a new article. Those screens could be implemented with in Views like ArticlesView, ArticleView and CreateArticleView
The View is responsible for displaying the UI and trigger actions based on user interactions. Although there traditionally isn't a ViewController in MVVM, this MVVM guide has split the View part of MVVM into two parts, a View and a ViewController, where the only responsibility of the View component is to render the UI while the ViewController handles all the logic for the View.
View Component
As we saw before, the View will will look something like this with some CSS.
To implement the View, we use a regular functional React component that renders a UI. To avoid handling view logic in the View component, we use a ViewController which exports functions for all possible user actions.
// view/article/articles-view/ArticlesView.tsx
import React from 'react'
import useArticlesViewController from 'view/article/articles-view/useArticlesViewController'
const ArticlesView: React.FC = () => {
const {
articleName,
articles,
navigateToArticle,
onArticleNameChange,
onCreateArticleClick,
} = useArticlesViewController()
return (
<div>
{!!articles &&
articles.map(({ id, title }) => (
<div key={id} onClick={() => navigateToArticle(id)}>
{title}
</div>
))}
<div>
<input
type="text"
onChange={onArticleNameChange}
value={articleName}
placeholder="Add an article..."
/>
<button onClick={onCreateArticleClick}>Create article</button>
</div>
</div>
)
}
export default ArticlesView
The ArticlesView component above renders a view with articles. When the user interacts with the UI, this View component will simply forwards the action to a function in the useArticlesViewController hook.
Since this file only contains pure UI components and only reacts to user interactions by forwarding them to a hook, it's very trivial to test and read. The unit tests for this component would merely be to test that all articles are rendered and that the functions exported from the useArticlesViewController are called. The useArticlesViewController itself would preferably be mocked since we will test that hook separately.
To test interactions with the view, I would recommend interactional testing frameworks like React Testing Library. You can read more about the benefits and how that compares to code based unit testing in my other article about unit testing
I won't bother you with the CSS
ViewController Hook
The ViewController hook used in the View component handles the view logic for that View. Each View component has its own ViewController hook. There are several types of view logic in the ViewController.
- View logic that updates the View components internal state (typically useState, useReducer or useRef)
- View logic that updates the application's state (e.g. navigating to other screens)
- View logic that interacts with the ViewModel
In general, the View logic is responsible for managing the user interface and handling user interactions. This includes maintaining the component's internal state as well as handling conditions for interacting with the ViewModel. By keeping all this kind of view logic in the ViewController, we don't need any view logic in the ViewModel.
// view/article/articles-view/useArticlesViewController.tsx
import { useCallback, useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { HOME_SCREEN, ROOM_SCREEN } from 'routing/screens'
import useArticleViewModel from 'viewmodel/useArticleViewModel'
const useArticlesViewController = () => {
const history = useHistory()
const [articleName, setArticleName] = useState('')
const { articles, createArticle, getArticles } = useArticleViewModel()
const onCreateArticleClick = useCallback(async () => {
await createArticle({ name: articleName })
}, [createArticle, articleName])
const navigateToArticle = useCallback((articleId: number) => {
history.push(`${ROOM_SCREEN}/${articleId}`)
}, [history])
useEffect(() => {
getArticles()
}, [getArticles])
return {
articleName,
articles,
navigateToHome,
navigateToArticle,
onCreateArticleClick,
onArticleNameChange: setArticleName
}
}
export default useArticlesViewController
useArticlesViewController is a little more complex than its corresponding View component. It contains code for navigation among screens using React Router. It also keeps the internal state of the view, which can be used for both handling internal logical conditions or to be passed back to the View component to be used in rendering.
Lastly, we use a useArticleViewModel hook which is the ViewModel that this View uses. Only the functions required to be used in this View needs to be destructured from that ViewModel.
This file includes more logic to test than the View component. The good thing is that there's no need to test any UI elements at all. You can test the hook purely using React Hooks Testing Library, there's no need to use any UI element matchers or to check if data is rendered. Simply invoke the functions returned from the hook and assert the expected outcome.
React Anti-Patterns and Best Practices - Do's and Don'ts
Dennis Persson ・ Feb 5 '23
ViewModel Implementation
The ViewModel is responsible for handling the interactions between the View and the Model, serving as a bridge between them. Normally, the View interacts directly with the ViewModel, but as we already have seen, we have split the View into a View component and a ViewController. This means the ViewController is the one which uses the ViewModel.
The ViewModel is, just as the ViewController, implemented as a hook. It doesn't include very much code in our Article example. This is because our application example simply doesn't have any business logic. But if we would have it, we would have put it here.
// viewmodel/useArticleViewModel.tsx
import useArticleModel from 'model/useArticleModel'
const useArticleViewModel = () => {
const { article, articles, createArticle, getArticles } = useArticleModel()
return {
article,
articles,
createArticle,
getArticles
}
}
export default useArticleViewModel
Yeah, that's it. It only wraps the functions in the useArticleModel hook by re-exporting them. If some of those actions would have required any business logic, we would have created a wrapper function to perform the expected calculations within this hook.
What about testing? Well, when looking at this I would argue there's no need to test this file. With more logic, you can use React Hooks Testing Library here as well.
I didn't use to think testing is important, but somehow I changed my mind...
Model Implementation
The model is responsible for getting data from data sources, and serving it to the ViewModel. The ViewModel in turn, uses the Model to fetch and modify the data.
The model can be written in a plenty of ways. Redux, Apollo, hooks like useSWR and so on can all be used. Furthermore, there may not only be a single data source, complex applications can fetch data from several different sources. In that case, it can be a good idea to put a repository in between the ViewModel and the Model. Repositories are not a part of MVVM design pattern and will therefore not be used here.
Because the implementation of the model can vary significantly between projects, this guide will not delve deeply into how to implement it. Instead, we will provide a simple example that makes use of REST API functions for retrieving and posting articles.
// model/useArticleModel.tsx
import { useCallback, useState } from 'react'
import { getAllArticles, postArticle } from 'model/api/article'
import { ArticleDTO, CreateArticleDTO } from 'model/api/article'
const useArticleModel = () => {
const [articlesData, setArticlesData] = useState<ArticleDTO[] | null>(null)
const getArticles = useCallback(async () => {
const articles = await getAllArticles()
setArticlesData(articles)
}, [])
const createArticle = useCallback(async (createData: CreateArticleDTO) => {
if (Array.isArray(articlesData)) {
const response = await postArticle(createData)
if (response !== null) {
setArticlesData([...articlesData, { id: response.id, name: response.name }])
}
}
}, [articlesData])
return {
articles: articlesData,
createArticle,
getArticles
}
}
export default useArticleModel
As you can see, there's more logic in this one. Still we have kept it brief. The logic is not very smart or robust, but we can see that we have basic functionality for fetching and creating articles. The code should be fairly easily tested in a similar way as the previous hooks.
REST API
In the Model example above, we didn't get to see how the REST API functions were implemented. Even though it isn't necessary for the MVVM example, we will still look at some trivial example code for what it can look like.
This code will most likely not look the same in your project, it is simply here to serve an example. Your real code should hopefully be better written than this, typically you would need to handle things like errors, retires, caching and to make the code idempotent.
// model/api/article.ts
export interface ArticleDTO {
id: number
name: string
}
export interface CreateArticleDTO {
name: string
}
const API_URL = 'https://example.com/api'
export const getAllArticles = async (): Promise<ArticleDTO[] | null> => {
const response = await fetch(`${API_URL}/articles`)
if (!response.ok) {
throw new Error('Failed to get all articles')
}
const data = await response.json()
return data
}
export const postArticle = async (createData: CreateArticleDTO): Promise<ArticleDTO | null> => {
const response = await fetch(`${API_URL}/articles`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(createData)
})
if (!response.ok) {
throw new Error('Failed to create article')
}
const data = await response.json()
return data
}
FAQ
Q: What is a View?
A View is a substantial part of the UI. It can be the complete viewport or it can be a component within the viewport which should handle its own state and its own data. I don't know in which way you have worked with container and components in React, or smart and dumb components or whatever you call them. But if you think of a component as a dumb component, that is not a View, that's just a part of a View. Buttons, input field and small components are all components, not Views.
Q: Why is there a ViewController in the MVVM implementation?
The ViewController is added as a part of the View to split the user interface implementation from its logic. This allows the View to be tested more easily since the view logic and UI can be tested separately. The downside is that we get more components to test, render and maintain.
Another reason to add this extra layer, is to serve as a bridge between the View and the ViewModel. By doing this, we can keep more of the view logic inside the View layer instead of spilling it over to the ViewModel which is shared by multiple Views. There's no need for a ViewModel to know about the internal state's of the View that utilizes it.
Q: Do I really need a ViewController for my each View component?
That depends on what you think of when you say View. Read the question What is a View?. If the component you are writing isn't considered to be a View, you won't need, and neither shouldn't, write a ViewController for it. It is definitely overkill to add a ViewController for a trivial button component. If the component you implement really is a View, I would suggest adding its view logic to a ViewController for testing purpose and for being consistent with how you implement you Views.
Q: How to implement MVVM in React without a ViewController?
If you don't want to split the View into a View and ViewController, simply put all of the view logic in the ViewController into the View component itself. The other option would be to put parts or all of that logic in the ViewModel, but then the ViewModel would contain a lot of view logic that is specific to certain Views, which would make it confusing and quite huge.
Q: Why do we use hooks to implement the ViewController?
The ViewController could be implemented in multiple ways. We could for example implement the View and ViewController as two components, where the ViewController component would be the main component which simply passes props to the View component. That solution would increase the virtual and possible even physical DOM since it would require two components instead of one.
Q: Can we implement MVVM in React using old class components?
Yes, of course. But in this article we use functional components. Class components are old and should not be used anymore.
Q: I have seen MVVM being implemented using dependency injection in React.
Good for you. I have seen class components using that approach, which suits well there. Implementing MVVM in React with functional components comes with other solutions. Hooks are more familiar to React developers than dependency injection patterns. For that reason, this example uses React hooks to implement MVVM.
Q: Should my React MVVM application only include a single Model?
Probably not. You will likely have multiple models in your application. Each model will also come with a ViewModel which utilizes the Model.
Q: Should my React MVVM application only include a single ViewModel?
No. Each Model will have a ViewModel. If you have multiple Models you may also have multiple ViewModels.
Q: Should I have one or several ViewController for each ViewModel?
You should have one ViewController for each View. Several ViewControllers will use the same ViewModel. ViewControllers like useArticleViewController, useArticlesViewController and useCreateArticleViewController will all share the same ArticleViewModel.
Q: Can I only use a single ViewModel in a ViewController?
No, you can use many. The ViewController-ViewModel relation is a many-to-many relation, not a many-to-one relation. A View can use data from multiple models, and will in that case use multiple ViewModels. For example, a useArticleViewController, could utilize both a ArticleViewModel and a AuthorViewModel.
Q: Why do you wrap createArticle function in onCreateArticleClick in the ViewController?
This is done for two reasons. First reason is just for naming conventions, to keep view logic such as clicks handling in the ViewController rather than in the ViewModel. The second reason is because the ViewController has access to the internal state of the View. That makes it possible to update onCreateArticleClick with logic from the view, such as text from input fields.
Q: I have a component which doesn't need the Model do I still need use a ViewModel in the ViewController?
If I would answer yes, what would you write there? It's totally fine and even normal to have components without ViewModels.
Q: Do I still need testing libraries like React Test Renderer, Enzyme and React Testing Library?
Yes and no. You still have UI to test. But the only place you need UI testing libraries like those are in the View files, and those all consists of pure UI elements, which you arguably may not need to test. The hooks for the ViewController, ViewModel and Model can all be tested with React Hooks Testing Library. What so awesome with that? Well, look at the tiny documentation! It's all you need honestly.
Q: What folder structure would you recommend?
You can see the folder structure I used in this example by looking at the imports and filenames. In general, I would claim there are several folder structures that works, use whatever approach you (and at least some of your colleagues...) find logical.
Q: What if I don't want to use TypeScript?
Well, that's up to you. Just remove the types. They are only there to help.
Q: What if I use React Native?
You can use the same code for React Native as well with just some minor modifications.
Q: How does MVVM help with testing?
Two things decides how much tests will burden or save you. One thing is the architecture of the code. MVVM will by design make the code easier to test, by separating concerns of each layer in the application.
The other thing that helps you is how you structure your tests and what libraries you use to write the tests.
If you aren't satisfied with the FAQ, I know a guy you can ask