UX Studies with React, TypeScript, and Testing Library
I'm always willing to learn more and more. As a software engineer, the thirst for knowledge increased a lot. We have a lot of things to learn daily. But I wanted to master the foundation. To make me a better developer, I wanted to understand more about how to create great product experiences.
This post is my attempt to illustrate a Proof of Concept (PoC) I built to try some ideas as experiments.
I had some topics in mind to build this. It needs to strive for:
- High-quality software
- User Experience
When I say high-quality software, it can have so many different meanings. But I want to focus on three parts:
- Clean Code: Strive to write human-readable code, that is easy to read and simple to maintain. Separate responsibility for functions and components.
- Good test coverage: It's actually not about coverage. It's about tests that cover important parts of components' behavior without knowing too much about implementation details.
- Consistent state management: I want to build with software that enables the app to have consistent data. Predictability is important.
And user experience is the main focus of this PoC. The software and techniques are the foundation to enable good experience for users.
To make the state consistent, I wanted a type system. So I chose Typescript. This was the first time I'm using Typescript with React. This project also allowed me to build custom hooks and test it properly.
Setting up the project
I came across this library called tsdx that sets up all the Typescript configuration for you. It's mainly used to build packages. As this was a simple side project, I didn't mind giving it a try.
After installing it, I chose the react template and I was ready to code. But before the fun part, I wanted to set up the test configuration too. React Testing Library as the main library together with jest-dom to provide some awesome custom methods (I really like the toBeInTheDocument
matcher).
With all installed, I overwrote the jest config by adding a new jest.config.js
:
module.exports = {
verbose: true,
setupFilesAfterEnv: ['./setupTests.ts'],
};
And a setupTests.ts
to import everything I need.
import '@testing-library/jest-dom';
In this case, I just had the jest-dom
library to import. This way, I don't need to import this package in my test files. Now it works out of the box.
To test this installation and configuration, I built a simple component:
export const Thing = () => <h1>I'm TK</h1>;
In my test, I wanted to render it and see if it was in the DOM.
import React from 'react';
import { Thing } from '../index';
import { render } from '@testing-library/react';
describe('Thing', () => {
it('renders the correct text in the document', () => {
const { getByText } = render(<Thing />);
expect(getByText("I'm TK")).toBeInTheDocument();
});
});
And we are ready for the next step now!
Configuring routes
Here I'll have only two routes for now. The home page and the search page - even though I'll do nothing about the home page.
For this project, I'm using the react-router-dom
library to handle all things router. It's simple, easy, and fun to work with.
After installing it, I added the router components in the app.tsx
.
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
export const App = () => (
<Router>
<Switch>
<Route path="/search">
<h1>It's the search!</h1>
</Route>
<Route path="/">
<h1>It's Home</h1>
</Route>
</Switch>
</Router>
);
Now if we enter the localhost:1234
, we see the title It's Home
. Go to the localhost:1234/search
, we see the text It's the search!
.
Before we continue to start implementing our search page, I wanted to build a simple menu to switch between home and search pages without manipulating the URL. For this project, I'm using Material UI to build the UI foundation.
For now, we are just installing the @material-ui/core
.
To build the menu, we have the button to open the menu options. In this case "home" and "search" options. But to build a better component abstraction, I prefer to hide the content (link and label) for the menu items and make the Menu
component receive this data as a prop. This way, the menu doesn't know about the items, it will just iterate through the items list and render them.
It looks like this:
import React, { Fragment, useState, MouseEvent } from 'react';
import { MenuItem } from '../../types/MenuItem';
import Button from '@material-ui/core/Button';
import MuiMenu from '@material-ui/core/Menu';
import MuiMenuItem from '@material-ui/core/MenuItem';
import { Link } from 'react-router-dom';
type MenuPropsType = { menuItems: MenuItem[] };
export const Menu = ({ menuItems }: MenuPropsType) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleClick = (event: MouseEvent<HTMLButtonElement>): void => {
setAnchorEl(event.currentTarget);
};
const handleClose = (): void => {
setAnchorEl(null);
};
return (
<Fragment>
<Button aria-controls="menu" aria-haspopup="true" onClick={handleClick}>
Open Menu
</Button>
<MuiMenu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
{menuItems.map((item: MenuItem) => (
<Link to={item.linkTo} onClick={handleClose} key={item.key}>
<MuiMenuItem>{item.label}</MuiMenuItem>
</Link>
))}
</MuiMenu>
</Fragment>
);
};
export default Menu;
Don't panic! I know it is a huge block of code, but it is pretty simple. the Fragment
wrap the Button
and MuiMenu
(Mui
stands for Material UI. I needed to rename the component because the component I'm building is also called menu).
It receives the menuItems
as a prop and maps through it to build the menu item wrapped by the Link
component. Link is a component from react-router to link to a given URL.
The menu behavior is also simple: we bind the handleClick
function to the button's onClick
. That way, we can change anchorEl
when the button is triggered (or clicked if you prefer). The anchorEl
is just a component state that represents the Mui menu element to open the menu switch. So it will open the menu items to let the user chooses one of those.
Now, how do we use this component?
import { Menu } from './components/Menu';
import { MenuItem } from './types/MenuItem';
const menuItems: MenuItem[] = [
{
linkTo: '/',
label: 'Home',
key: 'link-to-home',
},
{
linkTo: '/search',
label: 'Search',
key: 'link-to-search',
},
];
<Menu menuItems={menuItems} />;
The menuItems
is a list of objects. The object has the correct contract expected by the Menu
component. The type MenuItem
ensures that the contract is correct. It is just a Typescript type
:
export type MenuItem = {
linkTo: string;
label: string;
key: string;
};
Search
Now we are ready to build the search page with all the products and a great experience. But before building the list of products, I wanted to create a fetch function to handle the request for products. As I don't have an API of products yet, I can just mock the fetch request.
At first, I just built the fetching with useEffect
in the Search
component. The idea would look like this:
import React, { useState, useEffect } from 'react';
import { getProducts } from 'api';
export const Search = () => {
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
useEffect(() => {
const fetchProducts = async () => {
try {
setIsLoading(true);
const fetchedProducts = await getProducts();
setIsLoading(false);
setProducts(fetchedProducts);
} catch (error) {
setIsLoading(false);
setHasError(true);
}
};
fetchProducts();
}, []);
};
I have:
products
initialized as an empty arrayisLoading
initialized as falsehasError
initialized as false- The
fetchProducts
is an async function that callsgetProducts
from theapi
module. As we don't have a proper API for products yet, thisgetProducts
would return a mock data. - When the
fetchProducts
is executed, we set theisLoading
to true, fetch the products, and then set theisLoading
to false, because the fetching finished, and the set the fetched products intoproducts
to be used in the component. - If it gets any error in the fetching, we catch them, set the
isLoading
to false, and thehasError
to true. In this context, the component will know that we had an error while fetching and can handle this case. - Everything is encapsulated into a
useEffect
because we are doing a side effect here.
To handle all the state logic (when to update each part for the specific context), we can extract it to a simple reducer.
import { State, FetchActionType, FetchAction } from './types';
export const fetchReducer = (state: State, action: FetchAction): State => {
switch (action.type) {
case FetchActionType.FETCH_INIT:
return {
...state,
isLoading: true,
hasError: false,
};
case FetchActionType.FETCH_SUCCESS:
return {
...state,
hasError: false,
isLoading: false,
data: action.payload,
};
case FetchActionType.FETCH_ERROR:
return {
...state,
hasError: true,
isLoading: false,
};
default:
return state;
}
};
The idea here is to separate each action type and handle each state update. So the fetchReducer
will receive the state and the action and it will return a new state. This part is interesting because it gets the current state and then returns a new state, but we keep the state contract by using the State
type.
And for each action type, we will update the state the right way.
FETCH_INIT
:isLoading
is true andhasError
is false.FETCH_SUCCESS
:hasError
is false,isLoading
is false, and the data (products) is updated.FETCH_ERROR
:hasError
is true andisLoading
is false.
In case it doesn't match any action type, just return the current state.
The FetchActionType
is a simple Typescript enum:
export enum FetchActionType {
FETCH_INIT = 'FETCH_INIT',
FETCH_SUCCESS = 'FETCH_SUCCESS',
FETCH_ERROR = 'FETCH_ERROR',
}
And the State
is just a simple type:
export type ProductType = {
name: string;
price: number;
imageUrl: string;
description: string;
isShippingFree: boolean;
discount: number;
};
export type Data = ProductType[];
export type State = {
isLoading: boolean;
hasError: boolean;
data: Data;
};
With this new reducer, now we can useReducer
in our fetch. We pass the new reducer and the initial state to it:
const initialState: State = {
isLoading: false,
hasError: false,
data: fakeData,
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
const fetchAPI = async () => {
dispatch({ type: FetchActionType.FETCH_INIT });
try {
const payload = await fetchProducts();
dispatch({
type: FetchActionType.FETCH_SUCCESS,
payload,
});
} catch (error) {
dispatch({ type: FetchActionType.FETCH_ERROR });
}
};
fetchAPI();
}, []);
The initialState
has the same contract type. And we pass it to the useReducer
together with the fetchReducer
we just built. The useReducer
provides the state and a function called dispatch
to call actions to update our state.
- State fetching: dispatch
FETCH_INIT
- Finished fetch: dispatch
FETCH_SUCCESS
with the products payload - Get an error while fetching: dispatch
FETCH_ERROR
This abstraction got very big and can be very verbose in our component. We could extract it as a separate hook called useProductFetchAPI
.
export const useProductFetchAPI = (): State => {
const initialState: State = {
isLoading: false,
hasError: false,
data: fakeData,
};
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
const fetchAPI = async () => {
dispatch({ type: FetchActionType.FETCH_INIT });
try {
const payload = await fetchProducts();
dispatch({
type: FetchActionType.FETCH_SUCCESS,
payload,
});
} catch (error) {
dispatch({ type: FetchActionType.FETCH_ERROR });
}
};
fetchAPI();
}, []);
return state;
};
It is just a function that wraps our fetch operation. Now, in the Search
component, we can import and call it.
export const Search = () => {
const { isLoading, hasError, data }: State = useProductFetchAPI();
};
We have all the API: isLoading
, hasError
, and data
to use in our component. With this API, we can render a loading spinner or a skeleton based on the isLoading
data. We can render an error message based on the hasError
value. Or just render the list of products using the data
.
Before starting implementing our products list, I want to stop and add tests for our custom hook. We have two parts to test here: the reducer and the custom hook.
The reducer is easier as it is just a pure function. It receives value, process, and returns a new value. No side-effect. Everything deterministic.
To cover all the possibilities of this reducer, I created three contexts: FETCH_INIT
, FETCH_SUCCESS
, and FETCH_ERROR
actions.
Before implementing anything, I set up the initial data to work with.
const initialData: Data = [];
const initialState: State = {
isLoading: false,
hasError: false,
data: initialData,
};
Now I can pass this initial state for the reducer together with the specific action I want to cover. For this first test, I wanted to cover the FETCH_INIT
action:
describe('when dispatch FETCH_INIT action', () => {
it('returns the isLoading as true without any error', () => {
const action: FetchAction = {
type: FetchActionType.FETCH_INIT,
};
expect(fetchReducer(initialState, action)).toEqual({
isLoading: true,
hasError: false,
data: initialData,
});
});
});
It's pretty simple. It receives the initial state and the action, and we expect the proper return value: the new state with the isLoading
as true
.
The FETCH_ERROR
is pretty similar:
describe('when dispatch FETCH_ERROR action', () => {
it('returns the isLoading as true without any error', () => {
const action: FetchAction = {
type: FetchActionType.FETCH_ERROR,
};
expect(fetchReducer(initialState, action)).toEqual({
isLoading: false,
hasError: true,
data: [],
});
});
});
But we pass a different action and expect the hasError
to be true
.
The FETCH_SUCCESS
is a bit complex as we just need to build a new state and add it to the payload attribute in the action.
describe('when dispatch FETCH_SUCCESS action', () => {
it('returns the the API data', () => {
const product: ProductType = {
name: 'iPhone',
price: 3500,
imageUrl: 'image-url.png',
description: 'Apple mobile phone',
isShippingFree: true,
discount: 0,
};
const action: FetchAction = {
type: FetchActionType.FETCH_SUCCESS,
payload: [product],
};
expect(fetchReducer(initialState, action)).toEqual({
isLoading: false,
hasError: false,
data: [product],
});
});
});
But nothing too complex here. The new data is there. A list of products. In this case, just one, the iPhone product.
The second test will cover the custom hook we built. In these tests, I wrote three contexts: a time-out request, a failed network request, and a success request.
Here, as I'm using axios
to fetch data (when I have an API to fetch the data, I will use it properly), I'm using axios-mock-adapter
to mock each context for our tests.
The set up first: Initializing our data and set up an axios mock.
const mock: MockAdapter = new MockAdapter(axios);
const url: string = '/search';
const initialData: Data = [];
We start implementing a test for the timeout request:
it('handles error on timed-out api request', async () => {
mock.onGet(url).timeout();
const { result, waitForNextUpdate } = renderHook(() =>
useProductFetchAPI(url, initialData),
);
await waitForNextUpdate();
const { isLoading, hasError, data }: State = result.current;
expect(isLoading).toEqual(false);
expect(hasError).toEqual(true);
expect(data).toEqual(initialData);
});
We set up the mock to return a timeout. The test calls the useProductFetchAPI
, wait for an update, and then we can get the state. The isLoading
is false, the data
is still the same (an empty list), and the hasError
is now true as expected.
The network request is pretty much the same behavior. The only difference is that the mock will have a network error instead of a timeout.
it('handles error on failed network api request', async () => {
mock.onGet(url).networkError();
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAPI(url, initialData),
);
await waitForNextUpdate();
const { isLoading, hasError, data }: State = result.current;
expect(isLoading).toEqual(false);
expect(hasError).toEqual(true);
expect(data).toEqual(initialData);
});
And for the success case, we need to create a product object to use it as a request-response data. We also expect the data
to be a list of this product object. The hasError
and the isLoading
are false in this case.
it('gets and updates data from the api request', async () => {
const product: ProductType = {
name: 'iPhone',
price: 3500,
imageUrl: 'image-url.png',
description: 'Apple mobile phone',
isShippingFree: true,
discount: 0,
};
const mockedResponseData: Data = [product];
mock.onGet(url).reply(200, mockedResponseData);
const { result, waitForNextUpdate } = renderHook(() =>
useFetchAPI(url, initialData),
);
await waitForNextUpdate();
const { isLoading, hasError, data }: State = result.current;
expect(isLoading).toEqual(false);
expect(hasError).toEqual(false);
expect(data).toEqual([product]);
});
Great. We covered everything we needed for this custom hook and the reducer we created. Now we can focus on building the products list.
Products list
The idea of the products list is to list products that have some information: title, description, price, discount, and if it has free shipping. The final product card would look like this:
To build this card, I created the foundation for the product component:
const Product = () => (
<Box>
<Image />
<TitleDescription />
<Price />
<Tag />
</Box>
);
To build the product, we will need to build each component that is inside it.
But before start building the product component, I want to show the JSON
data that the fake API will return for us.
{
imageUrl: 'a-url-for-tokyo-tower.png',
name: 'Tokyo Tower',
description: 'Some description here',
price: 45,
discount: 20,
isShippingFree: true,
}
This data is passed from the Search
component to the ProductList
component:
export const Search = () => {
const { isLoading, hasError, data }: State = useProductFetchAPI();
if (hasError) {
return <h2>Error</h2>;
}
return <ProductList products={data} isLoading={isLoading} />;
};
As I'm using Typescript, I can enforce the static types for the component props. In this case, I have the prop products
and the isLoading
.
I built a ProductListPropsType
type to handle the product list props.
type ProductListPropsType = {
products: ProductType[];
isLoading: boolean;
};
And the ProductType
is a simple type representing the product:
export type ProductType = {
name: string;
price: number;
imageUrl: string;
description: string;
isShippingFree: boolean;
discount: number;
};
To build the ProductList, I'll use the Grid
component from Material UI. First, we have a grid container and then, for each product, we will render a grid item.
export const ProductList = ({ products, isLoading }: ProductListPropsType) => (
<Grid container spacing={3}>
{products.map((product) => (
<Grid
item
xs={6}
md={3}
key={`grid-${product.name}-${product.description}-${product.price}`}
>
<Product
key={`product-${product.name}-${product.description}-${product.price}`}
imageUrl={product.imageUrl}
name={product.name}
description={product.description}
price={product.price}
discount={product.discount}
isShippingFree={product.isShippingFree}
isLoading={isLoading}
/>
</Grid>
))}
</Grid>
);
The Grid
item will display 2 items per row for mobile as we use the value 6
for each column. And for the desktop version, it will render 4 items per row.
We iterate through the products
list and render the Product
component passing all the data it will need.
Now we can focus on building the Product
component.
Let's start with the easiest one: the Tag
. We will pass three data to this component. label
, isVisible
, and isLoading
. When it is not visible, we just return null
to don't render it. If it is loading, we will render a Skeleton
component from Material UI. But after loading it, we render the tag info with the Free Shipping
label.
export const Tag = ({ label, isVisible, isLoading }: TagProps) => {
if (!isVisible) return null;
if (isLoading) {
return (
<Skeleton width="110px" height="40px" data-testid="tag-skeleton-loader" />
);
}
return (
<Box mt={1} data-testid="tag-label-wrapper">
<span style={tabStyle}>{label}</span>
</Box>
);
};
The TagProps
is a simple type:
type TagProps = {
label: string;
isVisible: boolean;
isLoading: boolean;
};
I'm also using an object to style the span
:
const tabStyle = {
padding: '4px 8px',
backgroundColor: '#f2f3fe',
color: '#87a7ff',
borderRadius: '4px',
};
I also wanted to build tests for this component trying to think of its behavior:
- when it's not visible: the tag will not be in the document.
describe('when is not visible', () => {
it('does not render anything', () => {
const { queryByTestId } = render(
<Tag label="a label" isVisible={false} isLoading={false} />,
);
expect(queryByTestId('tag-label-wrapper')).not.toBeInTheDocument();
});
});
- when it's loading: the skeleton will be in the document.
describe('when is loading', () => {
it('renders the tag label', () => {
const { queryByTestId } = render(
<Tag label="a label" isVisible isLoading />,
);
expect(queryByTestId('tag-skeleton-loader')).toBeInTheDocument();
});
});
- when it's ready to render: the tag will be in the document.
describe('when is visible and not loading', () => {
it('renders the tag label', () => {
render(<Tag label="a label" isVisible isLoading={false} />);
expect(screen.getByText('a label')).toBeInTheDocument();
});
});
- bonus point: accessibility. I also built an automated test to cover accessibility violations using
jest-axe
.
it('has no accessibility violations', async () => {
const { container } = render(
<Tag label="a label" isVisible isLoading={false} />,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
We are ready to implement another component: the TitleDescription
. It will work almost similar to the Tag
component. It receives some props: name
, description
, and isLoading
.
As we have the Product
type with the type definition for the name
and the description
, I wanted to reuse it. I tried different things - and you can take a look here for more details - and I found the Pick
type. With that, I could get the name
and the description
from the ProductType
:
type TitleDescriptionType = Pick<ProductType, 'name' | 'description'>;
With this new type, I could create the TitleDescriptionPropsType
for the component:
type TitleDescriptionPropsType = TitleDescriptionType & {
isLoading: boolean;
};
Now working inside the component, If the isLoading
is true, the component renders the proper skeleton component before it renders the actual title and description texts.
if (isLoading) {
return (
<Fragment>
<Skeleton width="60%" height="24px" data-testid="name-skeleton-loader" />
<Skeleton
style={descriptionSkeletonStyle}
height="20px"
data-testid="description-skeleton-loader"
/>
</Fragment>
);
}
If the component is not loading anymore, we render the title and description texts. Here we use the Typography
component.
return (
<Fragment>
<Typography data-testid="product-name">{name}</Typography>
<Typography
data-testid="product-description"
color="textSecondary"
variant="body2"
style={descriptionStyle}
>
{description}
</Typography>
</Fragment>
);
For the tests, we want three things:
- when it is loading, the component renders the skeletons
- when it is not loading anymore, the component renders the texts
- make sure the component doesn't violate the accessibility
We will use the same idea we use for the Tag
tests: see if it in the document or not based on the state.
When it is loading, we want to see if the skeleton is in the document, but the title and description texts are not.
describe('when is loading', () => {
it('does not render anything', () => {
const { queryByTestId } = render(
<TitleDescription
name={product.name}
description={product.description}
isLoading
/>,
);
expect(queryByTestId('name-skeleton-loader')).toBeInTheDocument();
expect(queryByTestId('description-skeleton-loader')).toBeInTheDocument();
expect(queryByTestId('product-name')).not.toBeInTheDocument();
expect(queryByTestId('product-description')).not.toBeInTheDocument();
});
});
When it is not loading anymore, it renders the texts in the DOM:
describe('when finished loading', () => {
it('renders the product name and description', () => {
render(
<TitleDescription
name={product.name}
description={product.description}
isLoading={false}
/>,
);
expect(screen.getByText(product.name)).toBeInTheDocument();
expect(screen.getByText(product.description)).toBeInTheDocument();
});
});
And a simple test to cover accessibility issues:
it('has no accessibility violations', async () => {
const { container } = render(
<TitleDescription
name={product.name}
description={product.description}
isLoading={false}
/>,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
The next component is the Price
. In this component we will provide a skeleton when it is still loading as we did in the other component, and add three different components here:
PriceWithDiscount
: we apply the discount into the original price and render itOriginalPrice
: it just renders the product priceDiscount
: it renders the discount percentage when the product has a discount
But before I start implementing these components, I wanted to structure the data to be used. The price
and the discount
values are numbers. So let's build a function called getPriceInfo
that receives the price
and the discount
and it will return this data:
{
priceWithDiscount,
originalPrice,
discountOff,
hasDiscount,
};
With this type contract:
type PriceInfoType = {
priceWithDiscount: string;
originalPrice: string;
discountOff: string;
hasDiscount: boolean;
};
In this function, it will get the discount
and transform it into a boolean
, then apply the discount
to build the priceWithDiscount
, use the hasDiscount
to build the discount percentage, and build the originalPrice
with the dollar sign:
export const applyDiscount = (price: number, discount: number): number =>
price - (price * discount) / 100;
export const getPriceInfo = (
price: number,
discount: number,
): PriceInfoType => {
const hasDiscount: boolean = Boolean(discount);
const priceWithDiscount: string = hasDiscount
? `$${applyDiscount(price, discount)}`
: `$${price}`;
const originalPrice: string = `$${price}`;
const discountOff: string = hasDiscount ? `${discount}% OFF` : '';
return {
priceWithDiscount,
originalPrice,
discountOff,
hasDiscount,
};
};
Here I also built an applytDiscount
function to extract the discount calculation.
I added some tests to cover these functions. As they are pure functions, we just need to pass some values and expect new data.
Test for the applyDiscount
:
describe('applyDiscount', () => {
it('applies 20% discount in the price', () => {
expect(applyDiscount(100, 20)).toEqual(80);
});
it('applies 95% discount in the price', () => {
expect(applyDiscount(100, 95)).toEqual(5);
});
});
Test for the getPriceInfo
:
describe('getPriceInfo', () => {
describe('with discount', () => {
it('returns the correct price info', () => {
expect(getPriceInfo(100, 20)).toMatchObject({
priceWithDiscount: '$80',
originalPrice: '$100',
discountOff: '20% OFF',
hasDiscount: true,
});
});
});
describe('without discount', () => {
it('returns the correct price info', () => {
expect(getPriceInfo(100, 0)).toMatchObject({
priceWithDiscount: '$100',
originalPrice: '$100',
discountOff: '',
hasDiscount: false,
});
});
});
});
Now we can use the getPriceInfo
in the Price
components to get this structure data and pass down for the other components like this:
export const Price = ({ price, discount, isLoading }: PricePropsType) => {
if (isLoading) {
return (
<Skeleton width="80%" height="18px" data-testid="price-skeleton-loader" />
);
}
const {
priceWithDiscount,
originalPrice,
discountOff,
hasDiscount,
}: PriceInfoType = getPriceInfo(price, discount);
return (
<Fragment>
<PriceWithDiscount price={priceWithDiscount} />
<OriginalPrice hasDiscount={hasDiscount} price={originalPrice} />
<Discount hasDiscount={hasDiscount} discountOff={discountOff} />
</Fragment>
);
};
As we talked earlier, when it is loading, we just render the Skeleton
component. When it finishes the loading, it will build the structured data and render the price info. Let's build each component now!
Let's start with the OriginalPrice
. We just need to pass the price
as a prop and it renders with the Typography
component.
type OriginalPricePropsType = {
price: string;
};
export const OriginalPrice = ({ price }: OriginalPricePropsType) => (
<Typography display="inline" style={originalPriceStyle} color="textSecondary">
{price}
</Typography>
);
Very simple! Let's add a test now.
Just pass a price and see it if was rendered in the DOM:
it('shows the price', () => {
const price = '$200';
render(<OriginalPrice price={price} />);
expect(screen.getByText(price)).toBeInTheDocument();
});
I also added a test to cover accessibility issues:
it('has no accessibility violations', async () => {
const { container } = render(<OriginalPrice price="$200" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
The PriceWithDiscount
component has a very similar implementation, but we pass the hasDiscount
boolean to render this price or not. If it has a discount, render the price with the discount. Otherwise, it won't render anything.
type PricePropsType = {
hasDiscount: boolean;
price: string;
};
The props type has the hasDiscount
and the price
. And the component just renders things based on the hasDiscount
value.
export const PriceWithDiscount = ({ price, hasDiscount }: PricePropsType) => {
if (!hasDiscount) {
return null;
}
return (
<Typography display="inline" style={priceWithDiscountStyle}>
{price}
</Typography>
);
};
The tests will cover this logic when it has or doesn't have the discount. If it hasn't the discount, the prices will not be rendered.
describe('when the product has no discount', () => {
it('shows nothing', () => {
const { queryByTestId } = render(
<PriceWithDiscount hasDiscount={false} price="" />,
);
expect(queryByTestId('discount-off-label')).not.toBeInTheDocument();
});
});
If it has the discount, it will be the rendered in the DOM:
describe('when the product has a discount', () => {
it('shows the price', () => {
const price = '$200';
render(<PriceWithDiscount hasDiscount price={price} />);
expect(screen.getByText(price)).toBeInTheDocument();
});
});
And as always, a test to cover accessibility violations:
it('has no accessibility violations', async () => {
const { container } = render(<PriceWithDiscount hasDiscount price="$200" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
The Discount
component is pretty much the same as the PriceWithDiscount
. Render the discount tag if the product has a discount:
type DiscountPropsType = {
hasDiscount: boolean;
discountOff: string;
};
export const Discount = ({ hasDiscount, discountOff }: DiscountPropsType) => {
if (!hasDiscount) {
return null;
}
return (
<Typography
display="inline"
color="secondary"
data-testid="discount-off-label"
>
{discountOff}
</Typography>
);
};
And all the tests we did for the other component, we do the same thing for the Discount
component:
describe('Discount', () => {
describe('when the product has a discount', () => {
it('shows the discount label', () => {
const discountOff = '20% OFF';
render(<Discount hasDiscount discountOff={discountOff} />);
expect(screen.getByText(discountOff)).toBeInTheDocument();
});
});
describe('when the product has no discount', () => {
it('shows nothing', () => {
const { queryByTestId } = render(
<Discount hasDiscount={false} discountOff="" />,
);
expect(queryByTestId('discount-off-label')).not.toBeInTheDocument();
});
});
it('has no accessibility violations', async () => {
const { container } = render(
<Discount hasDiscount discountOff="20% OFF" />,
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Now we will build an Image
component. This component has the basic skeleton as any other component we've built. If it is loading, wait to render the image source and render the skeleton instead. When it finishes the loading, we will render the image, but only if the component is in the intersection of the browser window.
What does it mean? When you are on a website on your mobile device, you'll probably see the first 4 products. They will render the skeleton and then the image. But below these 4 products, as you're not seeing any of them, it doesn't matter if we are rendering them or not. And we can choose to not render them. Not for now. But on-demand. When you are scrolling, if the product's image is at the intersection of the browser window, we start rendering the image source.
That way we gain performance by speeding up the page load time and reduce the cost by requesting images on demand.
We will use the Intersection Observer API to download images on demand. But before writing any code about this technology, let's start building our component with the image and the skeleton view.
Image props will have this object:
{
imageUrl,
imageAlt,
width,
isLoading,
imageWrapperStyle,
imageStyle,
}
The imageUrl
, imageAlt
, and the isLoading
props are passed by the product component. The width
is an attribute for the skeleton and the image tag. The imageWrapperStyle
and the imageStyle
are props that have a default value in the image component. We'll talk about this later.
Let's add a type for this props:
type ImageUrlType = Pick<ProductType, 'imageUrl'>;
type ImageAttrType = { imageAlt: string; width: string };
type ImageStateType = { isLoading: boolean };
type ImageStyleType = {
imageWrapperStyle: CSSProperties;
imageStyle: CSSProperties;
};
export type ImagePropsType = ImageUrlType &
ImageAttrType &
ImageStateType &
ImageStyleType;
The idea here is to give meaning for the types and then compose everything. We can get the imageUrl
from the ProductType
. The attribute type will have the imageAlt
and the width
. The image state has the isLoading
state. And the image style has some CSSProperties
.
At first, the component would like this:
export const Image = ({
imageUrl,
imageAlt,
width,
isLoading,
imageWrapperStyle,
imageStyle,
}: ImagePropsType) => {
if (isLoading) {
<Skeleton
variant="rect"
width={width}
data-testid="image-skeleton-loader"
/>;
}
return <img src={imageUrl} alt={imageAlt} width={width} style={imageStyle} />;
};
Let's build the code to make the intersection observer works.
The idea of the intersection observer is to receive a target to be observed and a callback function that is executed whenever the observed target enters or exits the viewport. So the implementation would be very simple:
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect,
options,
);
observer.observe(target);
Instantiate the IntersectionObserver
class by passing an options object and the callback function. The observer
will observe the target
element.
As it is an effect in the DOM, we can wrap this into a useEffect
.
useEffect(() => {
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect,
options,
);
observer.observe(target);
return () => {
observer.unobserve(target);
};
}, [target]);
Using useEffect
, we have two different things here: the dependency array and the returning function. We pass the target
as the dependency function to make sure that we will re-run the effect if the target
changes. And the returning function is a cleanup function. React performs the cleanup when the component unmounts, so it will clean up the effect before running another effect for every render.
In this cleanup function, we just stop observing the target
element.
When the component starts rendering, the target
reference is not set yet, so we need to have a guard to not observe an undefined
target.
useEffect(() => {
if (!target) {
return;
}
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect,
options,
);
observer.observe(target);
return () => {
observer.unobserve(target);
};
}, [target]);
Instead of using this effect in our component, we could build a custom hook to receive the target, some options to customize the configuration, and it would provide a boolean telling if the target is at the intersection of the viewport or not.
export type TargetType = Element | HTMLDivElement | undefined;
export type IntersectionStatus = {
isIntersecting: boolean;
};
const defaultOptions: IntersectionObserverInit = {
rootMargin: '0px',
threshold: 0.1,
};
export const useIntersectionObserver = (
target: TargetType,
options: IntersectionObserverInit = defaultOptions,
): IntersectionStatus => {
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
if (!target) {
return;
}
const onIntersect = ([entry]: IntersectionObserverEntry[]) => {
setIsIntersecting(entry.isIntersecting);
if (entry.isIntersecting) {
observer.unobserve(target);
}
};
const observer: IntersectionObserver = new IntersectionObserver(
onIntersect,
options,
);
observer.observe(target);
return () => {
observer.unobserve(target);
};
}, [target]);
return { isIntersecting };
};
In our callback function, we just set if the entry target is intersecting the viewport or not. The setIsIntersecting
is a setter from the useState
hook we define at the top of our custom hook.
It is initialized as false
but will update to true
if it is intersecting the viewport.
With this new information in the component, we can render the image or not. If it is intersecting, we can render the image. If not, just render a skeleton until the user gets to the viewport intersection of the product image.
How does it look in practice?
First we define the wrapper reference using useState
:
const [wrapperRef, setWrapperRef] = useState<HTMLDivElement>();
It start as undefined
. Then build a wrapper callback to set the element node:
const wrapperCallback = useCallback((node) => {
setWrapperRef(node);
}, []);
With that, we can use it to get the wrapper reference by using a ref
prop in our div
.
<div ref={wrapperCallback}>
After setting the wrapperRef
, we can pass it as the target
for our useIntersectionObserver
and expect a isIntersecting
status as a result:
const { isIntersecting }: IntersectionStatus =
useIntersectionObserver(wrapperRef);
With this new value, we can build a boolean value to know if we render the skeleton or the product image.
const showImageSkeleton: boolean = isLoading || !isIntersecting;
So now we can render the appropriate node to the DOM.
<div ref={wrapperCallback} style={imageWrapperStyle}>
{showImageSkeleton ? (
<Skeleton
variant="rect"
width={width}
height={imageWrapperStyle.height}
style={skeletonStyle}
data-testid="image-skeleton-loader"
/>
) : (
<img src={imageUrl} alt={imageAlt} width={width} />
)}
</div>
The full component looks like this:
export const Image = ({
imageUrl,
imageAlt,
width,
isLoading,
imageWrapperStyle,
}: ImagePropsType) => {
const [wrapperRef, setWrapperRef] = useState<HTMLDivElement>();
const wrapperCallback = useCallback((node) => {
setWrapperRef(node);
}, []);
const { isIntersecting }: IntersectionStatus =
useIntersectionObserver(wrapperRef);
const showImageSkeleton: boolean = isLoading || !isIntersecting;
return (
<div ref={wrapperCallback} style={imageWrapperStyle}>
{showImageSkeleton ? (
<Skeleton
variant="rect"
width={width}
height={imageWrapperStyle.height}
style={skeletonStyle}
data-testid="image-skeleton-loader"
/>
) : (
<img src={imageUrl} alt={imageAlt} width={width} />
)}
</div>
);
};
Great, now the loading on-demand works well. But I want to build a slightly better experience. The idea here is to have two different sizes of the same image. The low-quality image is requested and we make it visible, but blur while the high-quality image is requested in the background. When the high-quality image finally finishes loading, we transition from the low-quality to the high-quality image with an ease-in/ease-out transition to make it a smooth experience.
Let's build this logic. We could build this into the component, but we could also extract this logic into a custom hook.
export const useImageOnLoad = (): ImageOnLoadType => {
const [isLoaded, setIsLoaded] = useState(false);
const handleImageOnLoad = () => setIsLoaded(true);
const imageVisibility: CSSProperties = {
visibility: isLoaded ? 'hidden' : 'visible',
filter: 'blur(10px)',
transition: 'visibility 0ms ease-out 500ms',
};
const imageOpactity: CSSProperties = {
opacity: isLoaded ? 1 : 0,
transition: 'opacity 500ms ease-in 0ms',
};
return { handleImageOnLoad, imageVisibility, imageOpactity };
};
This hook just provides some data and behavior for the component. The handleImageOnLoad
we talked earlier, the imageVisibility
to make the low-quality image visible or not, and the imageOpactity
to make the transition from transparent to opaque, that way we make it visible after loading it.
The isLoaded
is a simple boolean to handle the visibility of the images. Another small detail is the filter: 'blur(10px)'
to make the low-quality-image blur and then slowly focusing while transitioning from the low-quality image to the high-quality image.
With this new hook, we just import it, and call inside the component:
const { handleImageOnLoad, imageVisibility, imageOpactity }: ImageOnLoadType =
useImageOnLoad();
And start using the data and behavior we built.
<Fragment>
<img
src={thumbUrl}
alt={imageAlt}
width={width}
style={{ ...imageStyle, ...imageVisibility }}
/>
<img
onLoad={handleImageOnLoad}
src={imageUrl}
alt={imageAlt}
width={width}
style={{ ...imageStyle, ...imageOpactity }}
/>
</Fragment>
The first one has a low-quality image, the thumbUrl
. The second has the original high-quality image, the imageUrl
. When the high-quality image is loaded, it calls the handleImageOnLoad
function. This function will make the transition between one image to the other.
Wrapping up
This is the first part of this project to learn more about user experience, native APIs, typed frontend, and tests.
For the next part of this series, we are going to think more in an architectural way to build the search with filters, but keeping the mindset to bring technical solutions to make the user experience as smooth as possible.
Resources
- Lazy Loading Images and Video
- Functional Uses for Intersection Observer
- Tips for rolling your own lazy loading
- Intersection Observer API - MDN
- React Typescript Cheatsheet