Home Java Todolist on React Hooks + TypeScript: from building to testing

Todolist on React Hooks + TypeScript: from building to testing

by admin

Starting with version 16.9, a new functionality is available in the React JS library – hooks They allow you to use state and other React functions, freeing you from having to write a class. Using functional components in conjunction with hooks allows you to develop a complete client application.
I suggest we consider building a version of the Todolist app on React Hooks using TypeScript

Assembly

The structure of the project is as follows :
├── src
| ├─ components
| ├─ index.html
| ├─ index.tsx
├─ package.json
├─ tsconfig.json
├── webpack.config.json
The package file.json:

{"name": "todo-react-typescript", "version": "1.0.0", "description": "", "main": "index.tsx", "scripts": {"start": "webpack-dev-server --port 3000 --mode development --open --hot", "build": "webpack --mode production"}, "author": "", "license": "ISC", "devDependencies": {"ts-loader": "^5.2.1", "html-webpack-plugin": "^3.2.0", "typescript": "^3.8.2", "webpack": "^4.41.6", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.10.3"}, "dependencies": {"@types/react": "^16.9.23", "@types/react-dom": "^16.9.5", "react": "^16.12.0", "react-dom": "^16.12.0"}}

To support TypeScript, in addition to the typescript package, you need ts-loader which compiles source tsx files into js-code, and packages with special data types for React – @types/react and @types/react-dom. Additionally we put html-webpack-plugin, it will provide correct work of dev-server if there is no index.html-file in project root, and it will create this file automatically for production-build in necessary place.
tsconfig.json file:

{"compilerOptions": {"sourceMap": true, "noImplicitAny": false, "module": "commonjs", "target": "es6", "lib": ["es2015", "es2017", "dom"], "removeComments": true, "allowSyntheticDefaultImports": false, "jsx": "react", "allowJs": true, "baseUrl": "./", "paths": {"components/*": ["src/components/*"]}}}

Field "jsx" defines source code compilation mode. There are 3 modes: "preserve", "react" and "react-native".
Todolist on React Hooks + TypeScript: from building to testing
webpack.config.json file:

const path = require('path');const HtmlWebpackPlugin = require('html-webpack-plugin');module.exports = {entry: './src/index.tsx', resolve: {extensions: ['.ts', '.tsx', '.js']}, output: {path: path.join(__dirname, '/dist'), filename: 'bundle.min.js'}, module: {rules: [{test: /\.ts(x?)$/, exclude: /node_modules/, use: [{loader: "ts-loader"}]}]}, plugins: [new HtmlWebpackPlugin({template: './src/index.html'})]};

The entry point of the application is ./src/index.tsx. Use resolve.extensions to allow ts/tsx/js files to be processed. Add ts-loader and html-webpack-plugin. The build is ready.

Development

In the file index.html we write the container where the application will be rendered :

<div id="root"> </div>

In the components directory we create our first so far empty component, App.tsx.
File index.tsx:

import * as React from 'react';import * as ReactDOM from 'react-dom';import App from "./components/App";ReactDOM.render (<App/> , document.getElementById("root"));

The Todolist application will have the following functionality :

  • add a task
  • delete a task
  • Change task status (completed / not completed)

It will look like this: a text box to enter + Add Task button, and below – the list of added tasks. You can delete tasks and change their status.
Todolist on React Hooks + TypeScript: from building to testing
For these purposes, you can divide the application into just two components – the creation of a new task and a list of all tasks. Therefore, the App.tsx at the initial stage will have the following appearance :

import * as React from 'react';import NewTask from ";/NewTask";import TasksList from ";/TasksList";const App = () => {return (<><NewTask /><TasksList /></>)}export default App;

In the current directory, create and export the empty components NewTask and TasksList. Since we need to ensure that they are interconnected, we need to determine how this will happen. There are two approaches to communication between components in React :

  1. Stores the current state of the application and all its methods in the parent component (in our case, in App.tsx) and passes it to the child components via props (the classic way);
  2. Storing state and state control methods separately. In this case, the application must be wrapped by a special component, a provider, and the methods and properties required for child components must be passed to it (using the useContexthook).

We will use the second way and in this example we will do away with props completely.
TypeScript when passing props * If you do pass props to a component, TypeScript will require an explicit type for the component :

const NewTask: React.FC<MyProps> = ({taskName}) => {...

The React.FC type, being a generic, expects to receive an interface (or type) for the parameters passed by the parent component :

interface MyProps {taskName: String;}

useContext

So, we’ll use the useContext hook to pass the stack. It allows you to retrieve and modify data in any of the components wrapped by the provider.
Example useContext

import * as React from 'react';import {useContext} from "react";interface Person {name: String, surname: String}export const PersonContext = React.createContext<Partial<Person> > ({});const PersonWrapper = () => {const person: Person = {name: 'Spider', surname: 'Man'}return (<><PersonContext.Provider value={ person }><PersonComponent /></PersonContext.Provider></>)}const PersonComponent = () => {const person = useContext(PersonContext);return (<div>Hello, {person.name} {person.surname}!</div>)}export default PersonWrapper;

In the example, we create an interface for the context – we will pass the name and surname fields, both of type String.
We create a context using the createContext method and pass an empty object into it so far. To prevent TypeScript from "swearing" about the absence of required interface fields, there is a special type Partial – it allows the absence of fields to be passed.
Then we pass data into created context – person object, and put component inside provider. Now this context will be available in any component, added inside the provider. You can call it just by usingContext hook.

useReducer

You will also need useReducer to make the state repository easier to use.
Read more about useReducer The useReducer hook allows controlling the state by calling a single function, but with different parameters: by convention, the name of the action is passed to the type field and the data is passed to the payload field. Example implementation :

import * as React from 'react';import {useReducer} from "react";interface PersonState {name: String, surname: String}interface PersonAction {type: 'CHANGE', payload: PersonState}const personReducer = (state: PersonState, action: PersonAction): PersonState => {switch (action.type) {case 'CHANGE':return action.payload;default: throw new Error('Unexpected action');}}const PersonComponent = () => {const initialState = {name: 'Unknown', surname: 'Guest'}const [person, changePerson] = useReducer<React.Reducer<PersonState, PersonAction> > (personReducer, initialState);return (<div onClick={() => changePerson({type: 'CHANGE', payload: {name: 'Jackie', surname: 'Chan'}})}>Hello, {person.name} {person.surname}!</div>)}export default PersonComponent;

In the useReducer we pass the function-reducer personReducer, which will work when changePerson is called.
The person variable will initially contain an initialState, which will be replaced with the value returned by the redirector in the course of changePerson calls.
In this example the updates will only occur on the CHANGE action, but the advantage of the redirector is that the logic can be expanded quickly and easily :

case 'CHANGE':return action.payload;case 'CLEAR':return {name: 'Undefined', surname: 'Undefined'};

useContext + useReducer

An interesting substitute for the Redux library could be to use context in conjunction with useReducer. In this case the context will pass the result of useReducer hook, i.e. the state it returns and the function to update it. Let’s add these hooks to the application :

import * as React from 'react';import {useReducer} from "react";import {Action, State, ContextState} from "/types/stateType";import NewTask from "./NewTask";import TasksList from "./TasksList";// initialState valuesexport const initialState: State = {newTask: '', tasks: []}//<Partial> allows you to create a context without default valuesexport const ContextApp = React.createContext<Partial<ContextState> > ({});//create a reducer which takes as input the current state and an Action object with the type and payload fields; the reducer's return value type is Stateexport const todoReducer = (state: State, action: Action):State => {switch (action.type) {case actionType.ADD: {return {...state, tasks: [...state.tasks, {name: action.payload, isDone: false}]}}case ActionType.CHANGE: {return {...state, newTask: action.payload}}case ActionType.REMOVE: {return {...state, tasks: [...state.tasks.filter(task => task !== action.payload)]}}case ActionType.TOGGLE: {return {...state, tasks: [...state.tasks.map((task) => (task !== action.payload ? task : {...task, isDone: !task.isDone})]}}default: throw new Error('Unexpected action');}};const App: React.FC = () => }// We use the created todoReducer by passing it as an argument to useReduser. Initially the initialState will get the initialState and then it will be updated during dispatch (changeState).const [state, changeState] = useReducer<React.Reducer<State, Action> > (todoReducer, initialState);const ContextState: ContextState = {state, changeState};// pass the results of useReducer into the context - the state and the method of its changereturn (<><ContextApp.Provider value={ContextState}><NewTask /><TasksList /></ContextApp.Provider></>)}export default App;

The result is an independent from the root component, which can be retrieved and changed in components within the provider.

Typescript.Adding types to an application

In the stateType file, write the TypeScript types for the application :

import {Dispatch}from "react";// the created task has a name and readiness statusExport type Task = {name: string;isDone: boolean}export type Tasks = Task[];// The state stores a new task to be written to the intut, as well as an array of tasks that have already been created.export type State = {newTask: string;tasks: Tasks}// all possible ways of working with the stateexport enum ActionType {ADD = 'ADD', CHANGE = 'CHANGE', REMOVE = 'REMOVE', TOGGLE = 'TOGGLE'}// Only string values can be passed for ADD and CHANGE actionstype ActionStringPayload = {type: actionType.ADD | actionType.CHANGE, payload: string}// For the actions TOGGLE and REMOVE only type Task object can be passedtype ActionObjectPayload = {type: actionType.TOGGLE | actionType.REMOVE, payload: Task}// combine the previous two groups to be used in the redirectorexport type Action = ActionStringPayload | ActionObjectPayload;// Our context consists of the stack and the function of the redrawer, which will receive the Action.The Dispatch type is imported from the library reactexport type ContextState = {state: State;changeState: Dispatch<Action>}

Use of context

The state is now ready and can be used in components. Let’s start with NewTask.tsx:

import * as React from 'react';import {useContext} from "react";import {ContextApp} from "/App";import {TaskName} from "../types/taskType";import {ActionType} from ";../types/stateType";const NewTask: React.FC = () => {// get state and dispatch-methodconst {state, changeState} = useContext(ContextApp);// we send two actions to the todoReducer - adding a task and changing an instance. After their successful processing the state variable will be updated. To clarify the interface of the transmitted event you can use the extended React-interfacesconst addTask = (event: React.FormEvent<HTMLFormElement> , task: TaskName) => {event.preventDefault();changeState({type: ActionType.ADD, payload: task})changeState({type: ActionType.CHANGE, payload: ''})}// similarly - send the value change in the instanceconst changeTask = (event: React.ChangeEvent<HTMLInputElement> ) => {changeState({type: ActionType.CHANGE, payload: event.target.value})}return (<><form onSubmit={(event)=> addTask(event, state.newTask)}><input type='text' onChange={(event)=> changeTask(event)} value={state.newTask}/><button type="submit"> Add a task</button></form></>)};export default NewTask;

TasksList.tsx:

import * as React from 'react';import {Task} from ";../types/taskType";import {ActionType} from "../types/stateType";import {useContext} from "react";import {ContextApp} from "./App";const TasksList: React.FC = () => {// Getting the state and dispatch (named changeState)const {state, changeState} = useContext(ContextApp);const removeTask = (taskForRemoving: Task) => {changeState({type: ActionType.REMOVE, payload: taskForRemoving})}const toggleReadiness = (taskForChange: Task) => {changeState({type: ActionType.TOGGLE, payload: taskForChange})}return (<><ul>{state.tasks.map((task, i)=> (<li key={i} className={task.isDone ? 'ready' : null}><label><input type="checkbox" onChange={()=> toggleReadiness(task)} checked={task.isDone}/></label><div className="task-name">{task.name}</div><button className='remove-button' onClick={()=> removeTask(task)}>X</button></li>))}</ul></>)};export default TasksList;

The app is ready! All that’s left is to test it.

Testing

For testing will be used Jest + Enzyme, as well as @testing-library/react
Dev dependencies must be installed :

"@testing-library/react": "^10.4.3", "@testing-library/react-hooks": "^3.3.0", "@types/enzyme": "^3.10.5", "@types/jest": "^24.9.1", "enzyme": "^3.11.0", "enzyme-adapter-react-16": "^1.15.2", "enzyme-to-json": "^3.3.4", "jest": "^26.1.0", "ts-jest": "^26.1.1", 

In package.json we add the settings for jest:

"jest": {"preset": "ts-jest", "setupFiles": ["./src/__tests__/setup.ts"], "snapshotSerializers": ["enzyme-to-json/serializer"], "testRegex": "/__tests__/.*\\.test.(ts|tsx)$", "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]}, 

and in the "scripts" block add a script to run the tests :

"test": "jest"

Create a new __tests__ directory in the src directory and in it create a setup.ts file with this content :

import {configure} from 'enzyme';import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';const adapter = ReactSixteenAdapter as any;configure({ adapter: new adapter() });

Create the file todoReducer.test.ts, in which we test the reducer :

import {todoReducer} from "../reducers/todoReducer";import {ActionType, Action, State} from "../types/stateType";import {Task} from "../types/taskType";describe('todoReducer', ()=> {it('returns new state for "ADD" type', ()=> {//create a state with an empty array of tasksconst initialState: State = {newTask: '', tasks: []};// create an 'ADD' action and pass the text 'new task' into itconst updateAction: Action = {type: ActionType.ADD, payload: 'new task'};//call the redo with the passed stack and actionconst updatedState = todoReducer(initialState, updateAction);// expect to get the added task in the stackexpect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: false}]})});it('returns new state for "REMOVE" type', () => {const task: Task = {name: 'new task', isDone: false}const initialState: State = {newTask: '', tasks: [task]};const updateAction: Action = {type: ActionType.REMOVE, payload: task};const updatedState = todoReducer(initialState, updateAction);expect(updatedState).toEqual({newTask: '', tasks: []});});it('returns new state for "TOGGLE" type', () => {const task: Task = {name: 'new task', isDone: false}const initialState: State = {newTask: '', tasks: [task]};const updateAction: Action = {type: ActionType.TOGGLE, payload: task};const updatedState = todoReducer(initialState, updateAction);expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: true}]});});it('returns new state for "CHANGE" type', () => {const initialState: State = {newTask: '', tasks: []};const updateAction: Action = {type: ActionType.CHANGE, payload: 'new task'};const updatedState = todoReducer(initialState, updateAction);expect(updatedState).toEqual({newTask: 'new task', tasks: []});});})

To test a redirector, just pass it the current state and action, and then catch the result of its execution.
Testing the App.tsx component, unlike redirector, requires additional methods from different libraries. The test file App.test.tsx:

import * as React from 'react';import {shallow} from 'enzyme';import {fireEvent, render, cleanup} from "@testing-library/react";import App from "/components/App";describe('<App /> ', () => {// the afterEach function with the cleanup collet passed is called after each test and clears the testing environmentafterEach(cleanup);it('hasn`t got changes', () => {// the shallow method of the enzyme library allows to perform unit testing, without drawing child components.const component = shallow(<App /> );// The first time you run the test, a snapshot of the component will be created. Subsequent tests will check if the snapshot is identical to the current content of the component. To update snapshots, you must run the test with the -u flag: jest -uexpect(component).toMatchSnapshot();});// Since the component will perform asynchronous actions (events on DOM elements will be called), wrap the test in asyncit('should render right input value', async () => {The // render() function is available in the @testing-library/react" library and differs from shallow() in that it builds a real DOM tree for the component under test. The container variable is the div element in which the component will be rendered.const { container } = render(<App/> );expect(container.querySelector('input').getAttribute('value')).toEqual(');//call the change event of the instance and pass the 'test' value therefireEvent.change(container.querySelector('input'), {target: {value: 'test'}, })// expect to get the value 'test' in the inputexpect(container.querySelector('input').getAttribute('value')).toEqual('test');// call the click event on the button. This event should clean up the output fieldfireEvent.click(container.querySelector('button'))// expect to get an empty value of the value attribute in the inputexpect(container.querySelector('input').getAttribute('value')).toEqual('');});})

In the TasksList component let’s check if the transmitted item is displayed correctly. The file TasksList.test.tsx:

import * as React from 'react';import {ContextApp, initialState} from "../components/App";import {shallow} from "enzyme";import {cleanup, render} from "@testing-library/react";import TasksList from "../components/TasksList";import {State} from "../types/stateType";describe('<TasksList /> ', () => {afterEach(cleanup);// Creating a test stateconst testState: State = {newTask: ", tasks: [{name: 'test', isDone: false}, {name: 'test2', isDone: false}]}// Passing the created test state toContextApp const Wrapper = () => {return (<ContextApp.Provider value={{state: testState}}><TasksList/></ContextApp.Provider>)}it('should render right tasks length', async () => {const {container} = render(<Wrapper/> );// Check the length of the displayed listexpect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length);});})

You can do a similar check of the newTask field for the NewTask component by checking the value of the input element.
The project can be downloaded from GitHub repository
That’s all for now, thanks for your attention.

Resources

React JS. Hooks
Working with React Hooks and TypeScript

You may also like