Clean MVVM with React and React Hooks
To cleanly develop and test our application, we would like to test all logic including that in the graphical user interface. To do this, we have to extract all GUI logic from the view into a view model which can be tested and developed in isolation.
I will illustrate a Clean architecture inspired approach to doing this.
Let's create a simple application of which one of its features is to manage a list of Products
- List products
- Create a product
- Delete an existing product
- Update an existing product
To get a visual idea of our Product management feature. Let's use wire framing:
Wire framing is an important communication tool in any app project. It gives the client and developer an opportunity to walk through the structure of the application without getting distracted by design implementations.
Our application will have the following User Interface Screens for Product Management:
This allows us to delay the thinking of user interfaces until the very end and lets us concentrate on the core, testable inner workings of the interface and the logic behind it.
Let's illustrate how the views will connect to the backend of the system with the following diagram:
We'll use React and React Hooks for this project
A good starting point for this React application is to create the groupings (folders) and containers (files) of code:
├─ Data
│ ├─ DataSource
│ │ └─ ProductDataSource.js
│ └─ Repository
│ └─ ProductRepository.js
├─ Domain
│ └─ UseCase
│ └─ Product
│ ├─ GetProducts.js
│ ├─ GetProduct.js
│ ├─ CreateProduct.js
│ ├─ UpdateProduct.js
│ └─ DeleteProduct.js
└─ Presentation
└─ View
└─ Product
├─ List
│ ├─ Components
│ │ ├─ ProductList.js
│ │ └─ AddButton.js
│ ├─ View.js
│ └─ ViewModel.js
├─ New
│ ├─ Components
│ │ ├─ NameTextField.js
│ │ ├─ PriceTextField.js
│ │ └─ SaveButton.js
│ ├─ View.js
│ └─ ViewModel.js
├─ Detail
│ ├─ Components
│ │ ├─ NameTextField.js
│ │ ├─ PriceTextField.js
│ │ ├─ UpdateButton.js
│ │ └─ DeleteButton.js
│ ├─ View.js
│ └─ ViewModel.js
└─ index.js
Product List View
1//Presentation/View/Product/List/View.js 2import React, { useEffect } from "react" 3import useViewModel from "./ViewModel" 4import ProductList from "./components/ProductList" 5import AddButton from "./components/AddButton" 6import { useNavigate } from "react-router-dom"; 7 8export default function ProductList() { 9 let navigate = useNavigate(); 10 const {products, getProducts, goToAddProduct, goToProductDetail } = useViewModel(); 11 12 useEffect(() => { 13 getProducts() 14 }, []) 15 16 return ( 17 <div className="page"> 18 <div className="header"> 19 <h2>Product List</h2> 20 <AddButton onClick={() => navigate(`/product/new`)} /> 21 </div> 22 <ProductTable data={products} onRowClick={(id) => navigate(`/product/detail/${id}`)} /> 23 </div> 24 ); 25}
Using react hooks and useEffect, we show how we'll load the products into the ProductTable on view load.
By simply refactoring the view components into separate files, we can keep the view easy to read and void of implementations.
These components can be developed in isolation and only need to adhere to the interface or touch points specified in the view (e.g. isBusy, onClick, onRowClick, data)
Product List view model
1//Presentation/View/Product/List/ViewModel.js 2import { useState } from "react" 3import getProductsUseCase from '../../Domain/UseCase/Product/GetProducts' 4 5export default function ProductListViewModel() { 6 7 const [error, setError] = useState(""); 8 const [products, setProducts] = useState([]); 9 10 async function getProducts(){ 11 const {result, error} = await getProductsUseCase() 12 setError(error) 13 setProducts(result) 14 } 15 16 return { 17 products, 18 getProducts 19 } 20}
New Product View and View Model
1//Presentation/View/Product/New/View.js 2import React, { useEffect } from "react" 3import useViewModel from "./ViewModel" 4import NameTextField from "./components/NameTextField" 5import PriceTextField from "./components/PriceTextField" 6import SaveButton from "./components/SaveButton" 7 8export default function NewProduct() { 9 const {saveProduct, name, price, onChange } = useViewModel(); 10 11 return ( 12 <div className="page"> 13 <div className="header"> 14 <h2>New Product</h2> 15 <SaveButton onClick={saveProduct} /> 16 </div> 17 <div className="form"> 18 <NameTextField onChange={onChange} value={name} name="name" /> 19 <PriceTextField onChange={onChange} value={price} name="price" /> 20 </div> 21 22 </div> 23 ) 24}
1//Presentation/View/Product/New/ViewModel.js 2import { useState } from "react" 3import createProductUseCase from '../../Domain/UseCase/Product/CreateProduct' 4 5export default function NewProductViewModel() { 6 const [error, setError] = useState("") 7 const [values, setValues] = useState({ 8 name: "", 9 price: 0 10 }) 11 12 function onChange(value, prop){ 13 setValues({...values, [prop]: value}) 14 } 15 16 async function saveProduct(){ 17 const {result, error} = await createProductUseCase(values) 18 setError(error) 19 } 20 21 return { 22 ...values, 23 onChange, 24 saveProduct 25 } 26}
Product Detail View and View Model
1//Presentation/View/Product/Detail/View.js 2import React, { useEffect } from "react"; 3import { useParams } from "react-router-dom"; 4import useViewModel from "./ViewModel"; 5import NameTextField from "./components/NameTextField"; 6import PriceTextField from "./components/PriceTextField"; 7import UpdateButton from "./components/UpdateButton"; 8import DeleteButton from "./components/DeleteButton"; 9 10export default function ProductDetail() { 11 const { id } = useParams(); 12 const {name, price, onChange, getProduct, updateProduct, deleteProduct } = useViewModel(); 13 14 useEffect(() => { 15 getProduct(id) 16 }, []) 17 18 return ( 19 <div className="page"> 20 <div className="header"> 21 <h2>Product Detail</h2> 22 <DeleteButton onClick={deleteProduct} /> 23 <UpdateButton onClick={updateProduct} /> 24 </div> 25 <div className="form"> 26 <NameTextField onChange={onChange} value={name} name="name" /> 27 <PriceTextField onChange={onChange} value={price} name="price" /> 28 </div> 29 30 </div> 31 ); 32}
1//Presentation/View/Product/Detail/ViewModel.js 2import { useState } from "react"; 3import updateProductUseCase from '../../Domain/UseCase/Product/UpdateProduct' 4import getProductUseCase from '../../Domain/UseCase/Product/GetProduct' 5import deleteProductUseCase from '../../Domain/UseCase/Product/DeleteProduct' 6 7export default function ProductDetailViewModel() { 8 9 const [error, setError] = useState("") 10 const [values, setValues] = useState({ 11 name: "", 12 price: 0 13 }) 14 15 16 function onChange(value, prop){ 17 setValues({...values, [prop]: value}) 18 } 19 20 async function deleteProduct(id){ 21 const {result, error} = await deleteProductUseCase(id) 22 setError(error) 23 } 24 25 async function getProduct(id){ 26 const {result, error} = await getProductUseCase(id) 27 setError(error) 28 setProduct(result) 29 } 30 31 async function updateProduct(){ 32 const {result, error} = await updateProductUseCase(id, values) 33 setError(error) 34 } 35 36 return { 37 ...values, 38 onChange, 39 deleteProduct, 40 getProduct, 41 updateProduct 42 }; 43}
Conclusion:
We decouple the view into isolated view models and refactor our view into a collection of view components we can develop in isolation. This allows all logic to be testable, including view logic.
GitHub Repo: https://github.com/nanosoftonline/clean-mvvm-react