React & Vite (Part 1)
Creating a TODO app with React and Vite in 2024. Includes React Context with Reducer combination for state management.
What is React?
React is a Javascript framework for building the front end of a website. It is commonly used for single page applications. One of the key ideas here, is that instead of doing page loads to retrieve new Html from a server, we use Javascript to generate the Html in the front end. Any new data that is required, is commonly provided via a Rest API that usually returns JSON data.
Another key idea is that of components. Old web doctrine was to keep styling, content, and logic completely separate. Libraries like React have a very different phillosophy. Instead we have a component that contains everything it needs across all three of these domains. This enables us to reuse components across our application, without worrying as much about things like conflicting css, what is in scope, etc.
There are Pros and Cons to a framework like React, which are beyond the scope of this post. My personal, and rather unpopular opinion, is that you should only use React for sites that benefit heavily from it’s features.
When deciding what framework to use, consider the following checklist…
- Does raw Html / Css, or a Static Site Generator fulfill my needs? If yes, use one.
- Does server side rendering fulfill my needs? Golang with Gin is my personal preference here.
- Does React or perhaps even a meta-framework like Next.js fulfill my needs?
I believe you should proceed in that order. The tooling and complexity generally increases as you go down that list. There is also a large time cost to maintaining a complex build system with many layers and constantly evolving dependencies.
What is Vite?
Vite is a build tool that is largely replacing Webpack. It provides a handy local development server, and also handles bundling your code. It also includes nice things like the ability to directly import image files into your Javascript and comes out of the box with speedy hot reloading.
I’ve never particularly enjoyed configuring Javascript build tools, but Vite has certainly been a more pleasent experience for me than Webpack. It’s also been my experience that Vite is significantly faster than Webpack.
The Youtube account “Fireship” has a nice Vite in 100 seconds video that I’d encourage you to checkout for a quick overview.
What is Vitest?
Vitest is a Javascript testing framework. I don’t have much experience with it, but I do have FAR too much experience with one of it’s main competitors, Jest. For me, that’s all the incentive I need to give Vitest a chance. Well, that and the promise of not waiting a full 15 minutes for my Jest tests to run.
Creating a new app using Vite
Okay. Lets start building.
Navigate to your projects directory, or wherever you create things. We’re going to need some dependencies. I’m assuming you have node and npm installed. Vite requires at least Node 18, but Node 20 is recommended.
Assuming you have those installed, run the command
npm create vite@latest
This will run a small program in the Terminal. It will ask for the project name (it will create a new directory for this), and ask some questions about the frameworks you intend to use. I selected React, and opted for Typescript.
Navigate to the directory you just created. Once there, run the following commands. This will install the dependencies, and run the vite development server.
npm install
npm run dev
If you’ve done everything right, you should now be able to go to localhost:5137 in a browser, and see something like the following. If this is your first foray into React, I’d encourage you to spend a little time looking at all the files, and figure out a little how the page is constructed.
Folder structure and routing
The default project is a fine starting point, but even our small TODO app is going to need a little more structure. Let’s make some folders in the src directory. Create the following folders; contexts, components, pages. There are many different ways to structure a React application, but this should suffice for a simple TODO app.
mkdir src/contexts src/components src/pages
With these folders in place, lets create the files we will need for our routing, contexts, components and pages.
touch src/Router.tsx src/contexts/cart.tsx src/components/Header.tsx src/pages/PageWithHeader.tsx src/pages/About.tsx src/pages/Shop.tsx src/pages/Cart.tsx
If you did everything right, and use a sexy editor like I do, your project structure should look like this. “Please note the 2 space indentation. I’m not sure how to configure vite for 4 space identation, but we’ll be deleting this code anyway, so it doesn’t really matter”
To avoid too much console red, we’re going to provide placholder components for the 3 main pages, About, Shop and Cart. Open up src/pages/About.tsx
.
Paste in the following Code.
export function About() {
return (
<div>Please go to the Shop and buy some stuff...</div>
)
}
This is about as basic a React component as we can create. It is a pure function that simply returns some Jsx. When this component is used, the div will be rendered with the text. Go ahead and copy the same code into src/pages/Shop.tsx
and src/pages/Cart.tsx
. Make sure to Change the function names to Shop
and Cart
respectively. I’d also change the text in the divs to something that makes it obvious which component is being used.
“If you are new to react, note the capitalisation of these functions. Functions that will be treated as React Components, should begin with a capital letter”
If we save those changes and go back to our web browser, we will see that our site looks no different. If you look in src/main.tsx
we will see why. We are rendering the App component, which is just the default page with the Vite logo and a counter. We need some routing.
For this we will need a very common additional dependency in React projects. Go ahead and install react-router-dom.
npm install react-router-dom
With this installed, open up src/Router.tsx
and paste in the following code
import {
createBrowserRouter,
RouterProvider,
} from 'react-router-dom'
import { About } from './pages/About'
import { Shop} from './pages/Shop'
import { Cart } from './pages/Cart'
export function Router() {
const router = createBrowserRouter(
[
{
path: "/",
element: <About />,
},
{
path: "/about",
element: <About />,
},
{
path: "/shop",
element: <Shop />,
},
{
path: "/cart",
element: <Cart />,
},
],
);
return (
<RouterProvider router={router} />
);
}
Here we are creating a basic router. We are telling React what components we want to display for a given Url.
To use the Router, we need to import it in src/main.tsx
, and render the Router component.
Modify src/main.tsx
to look like this.
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Router } from './Router'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Router />
</React.StrictMode>,
)
Once this is done, you can delete src/App.tsx
if you like.
Now when we run the app, we should see our About
component rendered. We can also see our Shop
and Cart
components by navigating to the relevant paths as defined by the router.
Adding a Navigation Bar
You may have noticed that we haven’t touched src/components/Header.tsx
or src/pages/PageWithHeader.tsx
. Lets open up src/components/Header.tsx
and create a basic top bar with links to all the pages.
import Logo from "../assets/react.svg"
import { Link } from "react-router-dom"
export function Header () {
return (
<header className="topbar">
<img src={Logo} className="topbar__logo"/>
<ul className="topbar__links">
<li><Link to="/about">About</Link></li>
<li><Link to="/shop">Shop</Link></li>
<li><Link to="/cart">Cart</Link></li>
</ul>
</header>
);
}
There are some note worthy things in this file.
- We can import svg’s directly, notice the Logo import. There is a caveat here though. If you want to import the svg as a React Component, and manipulate the currentColor and other properties from React, you need an extra plugin. We are going to bypass that complication for now, but you can find details here if needed.
- The Link import from react-router-dom. We have to use this. If you use a regular
<a href=...
, it will cause the page to fully reload, and everything in React world to be remounted. This is a problem for the cart we’re going to create shortly using React Context, as we would lose the cart state on a page reload. - Notice I’ve created some classes. Delete the entire contents of
src/App.css
. You may style the app however you want of course, but I’ve created some basic styling so we have something functional to look at.
:root {
--primary-color: #053d6f;
--primary-font-color: #050505;
--secondary-font-color: #fafafa;
--secondary-font-color-hover: #7cfffe;
--off-white: #fafaf5;
--off-black: #040404;
}
#root {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 100vh;
margin: 0;
padding: 0;
line-height: 1;
}
body {
min-height: 100vh;
}
.topbar {
padding: 8px 20px 8px 20px;
background-color: var(--primary-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.topbar__links {
display: flex;
gap: 2rem;
margin: 0;
list-style: none;
}
.topbar__links li > a {
display: inline-flex;
align-items: center;
color: var(--secondary-font-color);
text-decoration: none;
}
.topbar__links li > a:hover {
color: var(--secondary-font-color-hover);
text-decoration: underline;
}
I promise that we are getting close to something we can see on screen again 😀. Now rather than add the Header to every Page, we’re going to create a generic page component that includes the header. There are pros and cons to this approach, but I’ve chosen it so I can show the Outlet
feature of react-router-dom.
Open up src/pages/PageWithHeader.tsx
. This is the generic component that will contain our Outlet. Copy in the following…
import '../App.css'
import { Outlet } from 'react-router-dom'
import { Header } from '../components/Header'
export function PageWithHeader() {
return (
<>
<Header />
<Outlet />
</>
)
}
Finally, modify the router so that our page components are children of PageWithHeader
import {
createBrowserRouter,
RouterProvider,
} from 'react-router-dom'
import { PageWithHeader } from './pages/PageWithHeader'
import { About } from './pages/About'
import { Shop} from './pages/Shop'
import { Cart } from './pages/Cart'
export function Router() {
const router = createBrowserRouter([
{
element: <PageWithHeader />,
children: [
{
path: "/",
element: <About />,
},
{
path: "/about",
element: <About />,
},
{
path: "/shop",
element: <Shop />,
},
{
path: "/cart",
element: <Cart />,
},
],
},
]);
return (
<RouterProvider router={router} />
)
}
The Outlet
is a special component provided by react-router-dom, that basically tells react to render the child for the matching path. So now all our pages render a header, and then their page specific content. Run the app again, and hopefully you should have a working navigation page with 3 pages. This might be a good time to change the body text on the 3 pages to easily distinguish them if you didn’t to it earlier when I told you to 😏.
Great, we have 3 hopefully working navigation links.
The Shop
Lets create one more file, src/products.ts
touch src/products.ts
As we don’t have a database or anything like that, we’re just going to declare some types and a constant list of available products. Copy and paste the following into the file. Feel free to change or add the products as you see fit.
export interface Product {
id: string,
name: string,
price: number,
}
export interface CartProduct extends Product {
qty: number,
}
export const products: Product[] = [
{
id:"espresso-4910",
name: "Coding Hipster Espresso Machine",
price: 682.50,
},
{
id:"keyboardmech-138",
name: "Mechanical Keyboard (Cherry Red Switches)",
price: 150.99,
},
{
id:"keyboardmech-143",
name: "Mechanical Keyboard (Brown Switches)",
price: 165.99,
},
{
id:"rogertechmouse-4",
name: "RogerTech Laser Mouse",
price: 45,
},
{
id:"laptopstickers-20871",
name: "Anti-GraphQL affiliation Stickers",
price: 2.05,
},
];
export function productById(id: string): Product | null {
return products.find((p) => p.id === id) || null;
}
If you’re new to typescript, it’s worth noting here that you can extend interfaces, as seen above. The CartProduct interface includes all the fields of the Product interface, but additionally it has a qty.
The term interface here is a little strange. In most programming languages, an interface is usually a set of function definitions that a class must implement. You can do that too in typescript, but it’s strangely not common practice. Generally people use interface for declaring types, even though type is also a keyword.
Let’s make use of this product list in our shop component now. Update src/pages/Shop.tsx
with the following.
import { products } from '../products'
export function Shop() {
const addItemToCart = (productId: string) => {
console.log("TODO implement once we have a cart context", id);
};
return (
<div className="content">
<ul className="shoplist">
{
products.map(item =>
<li key={item.id}>
{item.name}: £{item.price.toFixed(2)}
<button onClick={() => addItemToCart(item.id)}>
Add to Cart
</button>
</li>
)
}
</ul>
</div>
)
}
In this component, we read the product list we just defined, map over it, and create a list item for each. Please note the key prop. React requires that items in a list of unique keys. React requires this to aid in deciding how things should re-render. It’s also not recommended to use the index in a loop for this key value. An Id that is unique to the list item is best.
Notice again I’ve added more css, so append the following to the end of your src/App.css
file
.content {
width: 100%;
flex-grow: 1;
margin: 32px auto 32px auto;
padding: 2rem;
max-width: 1000px;
background-color: var(--off-white);
box-shadow: 2px 2px 6px var(--off-black);
border-radius: 16px;
height: 100%;
}
.shoplist {
width: fit-content;
}
.shoplist li {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--off-black);
}
.shoplist button {
display: inline-flex;
padding-inline: 0;
padding-block: 0;
padding: 8px;
outline: 0;
border: 0;
margin-left: 10px;
border-radius: 8px;
cursor: pointer;
line-height: 1;
background-color: var(--primary-color);
color: var(--off-white);
}
.shoplist button:hover {
color: var(--off-black);
background-color: var(--secondary-font-color-hover);
}
“Please note that this is absolutely not production ready css. It was hacked together quickly as it’s not the focus of this post”
If we run the app again, we should now have a shop page that looks something like this.
Starting to look like a rather rushed application, but unfortunately, the Add to Cart buttons do nothing except write a log to the console.
To have a cart, we need persistent state. Redux used to be the standard answer to this for larger apps, but since React introduced the Context Api, many teams now use that instead. Combining React Context with a Reducer is a really common interview challenge, so that’s what we’ll do here.
React Context
React Context and Reducer is really the meat and potatoes of this guide.
React Context is a way to access state deep down the tree without passing props at every stage. Reducer in react is just a fancy name for a function that takes in an action and some state, performs the action, and returns the new state. One key restriction of the reducer function is that the initial state cannot be mutated. You must return new objects, else you get very strange books if your tooling even lets you do it at all.
This will make more sense when you see it, but I’m going to barf out the contents of src/contexts/cart.tsx
.
import React, { useReducer, createContext } from 'react'
import { CartProduct, productById } from '../products'
interface CartState {
products: CartProduct[];
}
type AddToCart = {
type: 'addToCart',
id: string,
};
type ReduceQtyFromCart = {
type: 'reduceQtyFromCart',
id: string,
qty: number,
};
type CartAction = AddToCart | ReduceQtyFromCart;
const initialCartState: CartState = {
products: [],
};
export const CartContext = createContext<{state: CartState, dispatch: React.Dispatch<CartAction>}>({
state: initialCartState,
dispatch: () => {},
});
export const cartReducer = (state: CartState, action: CartAction) => {
const found = state.products.find((product) => action.id === product.id);
const rest = found
? state.products.filter((product) => action.id !== product.id)
: state.products;
const product = productById(action.id);
if (!product) {
return state;
}
switch (action.type) {
case 'addToCart':
let updated = {
...product,
qty: 1,
};
if (found) {
updated = {
...found,
qty: found.qty + 1,
}
}
return {
products: [
...rest,
updated
]
}
case 'reduceQtyFromCart':
if (found) {
const newQty = found.qty - action.qty;
if (newQty > 0) {
const updated = {
...found,
qty: newQty,
};
return {
products: [
...rest,
updated,
],
}
} else {
return {
products: [
...rest,
],
}
}
}
return state;
default:
return state;
}
}
export const CartProvider = ({children}: {children: React.ReactNode}) => {
const [ state, dispatch ] = useReducer(cartReducer, initialCartState);
return (
<CartContext.Provider value={{state, dispatch}}>
{children}
</CartContext.Provider>
)
}
Okay. I realise this is a bit involved. so lets examine each part in detail.
- CartState: The type our context will store.
- CartAction: A union type of all actions that can be performed on the state.
- CartContext: This is the actual instance that holds that data, and gives us access to the provider.
- cartReducer: The function that describes how to produce a new state object from a given action.
- CartProvider: A function that wraps an instance of our reducer, and returns our provider with state and dispatch.
CartState
CartState is the type our context will store. In our case, we have a single key products
that will hold an array of the CartProduct
type we defined earlier in src/products.ts
. This is the type of state that our reducer must take, and return.
CartAction
CartAction is a union of all action types that we have defined. If we want to add an action, we should define a new type, add it to the union, then add a case in our reducers switch statement to handle it.
These action types are what we will ultimately pass to the dispatch function provided by our context. Actions that go into the dispatch function get passed to the reducer and are handled in the switch statement.
CartContext
This is the actual thing that holds onto our state, and gives us a provider. We define the context in this file, a single instance, and then get a provider from it inside our CartProvider function.
cartReducer
This is a function of the form (CartState, CartAction) -> CartState
. This means it takes a CartState, takes a CartAction, and returns a CartState.
The reducer function itself probably looks very strange if you aren’t familiar with it, so lets look at addToCart
.
case 'addToCart':
let updated = {
...product,
qty: 1,
};
if (found) {
updated = {
...found,
qty: found.qty + 1,
}
}
return {
products: [
...rest,
updated
]
}
This says that if we receive an action of addToCart, we need to return a new list of products. I’m computing found
and rest
before the switch statement, as they are needed in all three branches.
If the item is already in the cart, we need to increase it’s qty by 1. However, we cannot simply do found.qty += 1
. This would be a state mutation, and we aren’t allowed to do that. Instead, we have to create a new CartItem in it’s place. We achieve this by spreading the other cart items into a new array, and then creating a new updated item for our existing item, that does not involve mutating the original item.
As a result of mutation not being allowed, you often get code that contains a lot of spreading into new objects or arrays like this. Reducers in Redux used to look almost exactly like this too, though modern redux reduces boiler plate by giving you a clone of the state, which you can freely mutate. It then finds all the changes under the hood, and produces a new clean state based on your changes.
The best advice I can give regarding React Context, is to primarily use it with fairly flat data structures, or find a good library to help with creating the new state, as this kind of code can get very gnarly.
CartProvider
Not to be confused with the actual Provider which is linked to the context, see CartContext.Provider
, this function is a wrapper around that. We wrap the context provider, giving it the initial state from our reducer, and the dispatch function instance. This means that in our react code, any component that is a child of the CartProvider component, can call the useContext hook with CartContext, and retrieve the current state and dispatch. It does not need to be a direct child either. That’s one advantage of context. You can potentially avoid passing state through many layers.
It’s worth noting, that it’s a fairly common pattern to not actually use the dispatch that we put in the context here. Many people define a function for each action here that uses the dispatch, and return those functions as part of the context state. This can be some nice house keeping, but I’ve left the dispatch as is, so you can see it being used directly in our React Components, without the extra layer of indirection.
Ok, but how do I use it?
Alright, alright. These are the steps, and I’ll give you the code.
- We need to wrap anything that needs access to the context in the CartProvider. I’m going to do this even above the Router level.
- In our Shop page, we will call useContext to get the dispatch function, then call it when we press any of the add to cart buttons with the appropriate action.
- In our Cart page, we will call useContext to get both the state and the dispatch function. We will use the state to tell us what is currently in our cart, and we will use dispatch to remove items from the cart.
So step 1
Add the provider, here is our new src/main.tsx
. Note the CartProvider that now wraps the Router.
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Router } from './Router'
import './index.css'
import { CartProvider } from './contexts/cart'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<CartProvider>
<Router />
</CartProvider>
</React.StrictMode>,
)
Step 2
Implement the addToCart functionality. Here is our new src/pages/Shop.tsx
. See that we are importing useContext, getting the dispatch, and calling that with the correct action data inside the event handler.
import { useContext } from 'react'
import { products } from '../products'
import { CartContext } from '../contexts/cart'
export function Shop() {
const { dispatch } = useContext(CartContext);
const addItemToCart = (productId: string) => {
dispatch({
type: "addToCart",
id: productId,
})
};
return (
<div className="content">
<ul className="shoplist">
{
products.map(item =>
<li key={item.id}>
{item.name}: £{item.price.toFixed(2)}
<button onClick={() => addItemToCart(item.id)}>
Add to Cart
</button>
</li>
)
}
</ul>
</div>
)
}
Notice the addItemToCart function. This is what I meant earlier. Some people will define this function inside the context itself. So instead of getting the dispatch from the context, we’d instead have this. As stated I avoided this step because it’s not strictly necessary, and it shows the dispatch in use directly to hopefully avoid confusion.
const { addItemToCart } = useContext(CartContext);
Step 3
Use the context in the cart page. We haven’t built the cart page yet, so here is the full thing in all it’s glory. Replace everything insrc/pages/Cart.tsx
with this. Don’t get confused between the two different cart files we have!. One is for the cart context, the other is for the cart page component.
import { useContext } from 'react'
import { CartProduct } from '../products'
import { CartContext } from '../contexts/cart'
function sortCart(a: CartProduct, b: CartProduct) {
if (a.id < b.id) {
return 1;
}
if (a.id > b.id) {
return -1
}
return 0;
}
export function Cart() {
const { state, dispatch } = useContext(CartContext);
const removeItemFromCart = (productId: string) => {
dispatch({
type: 'reduceQtyFromCart',
id: productId,
qty: 1,
});
};
return (
<div className="content">
<ul className="shoplist">
{
state.products.sort(sortCart).map(item =>
<li key={item.id}>
Qty {item.qty}: {item.name}: £{item.price.toFixed(2)}
<button onClick={() => removeItemFromCart(item.id)}>
Remove One
</button>
</li>
)
}
</ul>
</div>
)
}
And there we have it. We can add items to our cart on the Shop page, view them and or remove them on the cart page. Now we need to add some tests, but at this late hour, and given the length of this post, I think I’ll leave that to Part 2.