Skip to main content Link Menu Expand (external link) Left Arrow Right Arrow Document Search Copy Copied

Upgrading a Demo SPA

In this section, we will go through the process of upgrading an existing Next.js to make use of the MVVM architecture.

The demo application can be cloned from the following GitHub repository. Make sure that you are on the main branch.

The demo SPA renders a basic shopping list. The user can add items to the list by typing the name of the item in the “Item Name” box and clicking the “Add Item” button. Items are randomly assigned an integer price between $1 CAD and $20 CAD. Clicking the “Delete Last Item” button will delete the last item in the shopping list.

The items are stored in JSON file located under json/data.json.

CSS Files

Start by creating a styles directory under the root directory of the project. Move the CSS files located in the app directory to styles directory. Make sure to modify the import statement in page.tsx as shown below (if your IDE did not already do that for you):

import styles from '../styles/page.module.css'

Components

Create a components directory in the root directory of the project. The components directory usually contains custom or reusable UI elements. Our SPA uses custom buttons that are declared the pages.tsx file. We will move the custom button code to the components directory.

Start by creating a CustomButton directory inside the components directory. Create a CustomButton.tsx file inside the CustomButton directory.

Move the code responsible for creating custom buttons to the CustomButton.tsx:

import styled from '@emotion/styled'
import MuiButton, {ButtonProps} from '@mui/material/Button'

// Custom Button
interface CustomButtonProps extends ButtonProps {
    mainColor: string
}

export const CustomButton = styled(MuiButton, {shouldForwardProp: (prop) => prop !== "mainColor"})<CustomButtonProps>(props => ({
    backgroundColor: props.mainColor === 'green' ? '#77DD77' : '#FF6961',
    ':hover': {
        backgroundColor: props.mainColor === 'green' ? '#18A558':'#A80900',
    },
    borderRadius: 28
}));

Notice that we need to export the CustomButton constant now. We can now import the CustomButton component in the pages.tsx file:

import {CustomButton} from "@/components/CustomButton/CustomButton";

The template.tsx file contains the code responsible for rendering the light/dark mode switch. We will move this code to a separate ModeSwitch component.

Create a ModeSwitch subdirectory inside the components directory with a ModeSwitch.tsx file inside it.

Move the code responsible for rendering the dark/light mode switch to ModeSwitch.tsx:

import {Box, IconButton} from "@mui/material";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import Brightness4Icon from "@mui/icons-material/Brightness4";
import React from "react";
import {useTheme} from '@mui/material/styles'
import {ColorModeContext} from "@/app/template";

export default function ModeSwitch() {
    const theme = useTheme()
    const colorMode = React.useContext(ColorModeContext)

    return (
        <Box
            sx=
        >
            {theme.palette.mode.charAt(0).toUpperCase() + theme.palette.mode.slice(1)} Mode
            <IconButton sx= onClick={colorMode.toggleColorMode} color="inherit">
                {theme.palette.mode === 'dark' ? <Brightness7Icon /> : <Brightness4Icon />}
            </IconButton>
        </Box>
    )
}

We can now import the ModeSwitch component and use it inside the template.tsx file. Your template.tsx file should look like this:

'use client';

import CssBaseline from "@mui/material/CssBaseline";
import useMediaQuery from "@mui/material/useMediaQuery";
import React from "react";
import {createTheme, ThemeProvider} from '@mui/material/styles'
import themeOptions from "@/config/theme";
import ModeSwitch from "@/components/ModeSwitch/ModeSwitch";

export const ColorModeContext = React.createContext({
    toggleColorMode: () => {},
})

export default function Template({children}: {children?: React.ReactNode} ) {
    const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)')

    const [themeMode, setThemeMode] = React.useState<'light' | 'dark' | null>(null)


    const theme = React.useMemo(
        () =>
            createTheme({
                ...themeOptions,
                palette: {
                    mode:
                        themeMode == null
                            ? prefersDarkMode
                                ? 'dark'
                                : 'light'
                            : themeMode,
                },
            }),
        [themeMode, prefersDarkMode]
    )

    const colorMode = React.useMemo(
        () => ({
            toggleColorMode: () => {
                setThemeMode(prevMode => (prevMode == null ? (theme.palette.mode === 'dark' ? 'light' : 'dark') : prevMode === 'light' ? 'dark' : 'light'))
            },
        }),
        [theme]
    )

    return (
        <>
            <ColorModeContext.Provider value={colorMode}>
                <ThemeProvider theme={theme}>
                    <ModeSwitch />
                    <CssBaseline />
                    {children}
                </ThemeProvider>
            </ColorModeContext.Provider>

        </>
    )
}

Create the Model

In the root directory of your project, create a new models directory with an ItemModel.ts file inside of it. The ItemModel should contain the code responsible for fetching items, adding a new item and delete the most recent from the list.

Add the following code to ItemModel.ts:

import {useCallback, useState} from 'react'
import {getAllItems, Item, postItem, removeItem} from "@/app/api/items/item";

const ItemModel = () => {
    const [items, setItems] = useState<Item[]>([]);

    const getItems = useCallback(async () => {
        const fetched_items = await getAllItems()
        if (fetched_items) {
            setItems(fetched_items)
        }
    }, [])

    const addItem = useCallback(async (itemText: string) => {
        if (Array.isArray(items)) {
            const response = await postItem(itemText)

            if (response !== null) {
                const fetched_items = await getAllItems()
                if (fetched_items) {
                    setItems(fetched_items)
                }
            }
        }
    }, [items])

    const deleteLastItem = useCallback(async () => {
        if (Array.isArray(items)) {
            const response = await removeItem()

            if (response !== null) {
                const fetched_items = await getAllItems()
                if (fetched_items) {
                    setItems(fetched_items)
                }
            }
        }
    }, [items])

    return {
        items: items,
        getItems,
        addItem,
        deleteLastItem
    }
}

export default ItemModel

Create the ViewModel

In the MVVM design pattern, the Model should not communicate directly with the View. Instead, we have to make use of a ViewModel to handle modifying the user interface on the View and passing information from the Model to the View.

Create a viewmodels directory in the root directory of your project with an ItemViewModel.tsx file inside of it.

We will move the useEffect hook responsible for fetching the items, as well as the handleChange, handleAddItem and handleDeleteItem functions to the ViewModel.

Add the following lines of code to ItemViewModel.tsx:

import ItemModel from "@/models/ItemModel";
import {useCallback, useEffect, useState} from "react";

const ItemViewModel = () => {
    const [itemName, setItemName] = useState<string>('');
    const { items, getItems, addItem, deleteLastItem } = ItemModel()

    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        if (event.target.value) {
            setItemName(event.target.value);
        }
    }

    const handleAddItem = useCallback(async () => {
        if (itemName) {
            await addItem(itemName)
            setItemName('');
        }
    }, [addItem, itemName])

    const handleDeleteItem = useCallback(async () => {
        await deleteLastItem()

    }, [deleteLastItem])

    useEffect(() => {
        getItems()
    }, [getItems])


    return {
        itemName,
        items,
        handleChange,
        handleAddItem,
        handleDeleteItem,
    }
}

export default ItemViewModel

Update the View

Now that we have proper Model and ViewModel files, we will modify the View by updating the app/page.tsx file as shown below:

'use client';

import styles from '../styles/page.module.css'
import {Box, Grid, List, ListItem, Stack, TextField, Typography} from "@mui/material";
import {CustomButton} from "@/components/CustomButton/CustomButton";
import ItemViewModel from "@/viewmodels/ItemViewModel";
import {Item} from "@/app/api/items/item";


export default function Home() {
    const {
        itemName,
        items,
        handleChange,
        handleAddItem,
        handleDeleteItem,
    } = ItemViewModel();

    const imgStyle = {
        paddingTop: '10px',
        paddingBottom: '10px',
        paddingRight: '30px',
    }

    return (
        <main className={styles.main}>
            <>
                <Stack direction={"column"} spacing={2}>
                    <div style=>
                        <Box
                            sx=
                            component="img"
                            alt="Shopping Cart Logo"
                            src="/assets/shopping-cart.png"
                            style={imgStyle}
                        />
                    </div>
                    <TextField id="item-field" label="Item Name" variant="outlined" value={itemName} onChange={handleChange} />
                    <Stack direction={"row"} spacing={2}>
                        <CustomButton mainColor={"green"} variant={"contained"} onClick={handleAddItem}>
                            Add Item
                        </CustomButton>
                        <CustomButton mainColor={"red"} variant={"contained"} onClick={handleDeleteItem}>
                            Delete Last Item
                        </CustomButton>
                    </Stack>
                    <div>
                        <Typography variant={"h1"}>My Shopping List</Typography>
                        <List sx=>
                            {items.map((item: Item) => (
                                <ListItem sx= key={item.id}>
                                    <Grid container spacing={2}>
                                        <Grid item xs={5}>
                                            {item.name}
                                        </Grid>
                                        <Grid item xs={4}>
                                            {"$" + item.price + " CAD"}
                                        </Grid>
                                    </Grid>
                                </ListItem>
                            ))}
                        </List>
                    </div>

                </Stack>
            </>
        </main>
    )
}

We removed the code responsible for creating the CustomButton component. The CustomButton component is now imported from the CustomButton/CustomButton.tsx file in the components directory. We also removed the useEffect hook responsible for fetching the items, as well as the handleChange, handleAddItem and handleDeleteItem function declarations and replaced them with a call to the ItemViewModel.

Notice that the ItemModel is not used directly in the View since the View should not communicate directly with the View.