Let's consider creating a Todolist version of a React Hooks app using TypeScript .
Assembly
The project structure is as follows:
βββ src
| βββ components
| βββ index.html
| βββ index.tsx
βββ package.json
βββ tsconfig.json
βββ webpack.config.json
Package.json file:
TypeScript, typescript, ts-loader, tsx- js-, React β @types/react @types/react-dom. html-webpack-plugin, dev- index.html β , production- .
{
"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"
}
}
TypeScript, typescript, ts-loader, tsx- js-, React β @types/react @types/react-dom. html-webpack-plugin, dev- index.html β , production- .
Tsconfig.json file:
Β«jsxΒ» . 3 : Β«preserveΒ», Β«reactΒ» Β«react-nativeΒ».
{
"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/*"
]
}
}
}
Β«jsxΒ» . 3 : Β«preserveΒ», Β«reactΒ» Β«react-nativeΒ».
Webpack.config.json file:
β ./src/index.tsx. resolve.extensions ts/tsx/js . ts-loader html-webpack-plugin. .
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'
})
]
};
β ./src/index.tsx. resolve.extensions ts/tsx/js . ts-loader html-webpack-plugin. .
Development of
In the index.html file, we write the container where the application will be rendered:
<div id="root"></div>
In the components directory, create our first empty component, App.tsx.
Index.tsx file:
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from "./components/App";
ReactDOM.render (
<App/>,
document.getElementById("root")
);
The Todolist app will have the following functionality:
- add task
- delete task
- change task status (completed / not completed)
It will look like this: a text field for input + the Add task button, and below is a list of added tasks. You can delete tasks and change their status.
For these purposes, you can divide the application into just two components - creating a new task and a list of all tasks. Therefore, App.tsx at the initial stage will look like this:
import * as React from 'react';
import NewTask from "./NewTask";
import TasksList from "./TasksList";
const App = () => {
return (
<>
<NewTask />
<TasksList />
</>
)
}
export default App;
Create and export empty NewTask and TasksList components in the current directory. Since we need to ensure the relationship between them, we need to determine how this will happen. There are two approaches to communication between components in React:
- Storing the current state of the application and all its methods in the parent component (in our case, in App.tsx) and passing it to child components via props (the classic way);
- Keeping state and state management methods separate. In this case, the application needs to be wrapped with a special component - a provider, and the methods and properties necessary for the child components must be passed to it (using the useContext hook).
We will use the second method and in this example we will completely discard props.
TypeScript when passing props
* , TypeScript :
React.FC, , ( ) :
const NewTask: React.FC<MyProps> = ({taskName}) => {...
React.FC, , ( ) :
interface MyProps {
taskName: String;
}
useContext
So, to transfer the state, we will use the useContext hook. It allows you to get and modify data in any of the provider-wrapped components.
UseContext example
β name surname, String.
createContext . , TypeScript Β« Β» , Partial β .
β person, . , . 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;
β name surname, String.
createContext . , TypeScript Β« Β» , Partial β .
β person, . , . useContext.
useReducer
You will also need useReducer for more convenient work with the state store.
More about useReducer
useReducer , : , type, β payload. :
useReducer - personReducer, changePerson.
person initialState, changePerson .
CHANGE, , :
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;
useReducer - personReducer, changePerson.
person initialState, changePerson .
CHANGE, , :
case 'CHANGE':
return action.payload;
case 'CLEAR':
return {
name: 'Undefined',
surname: 'Undefined'
};
useContext + useReducer
An interesting replacement for the Redux library can be the use of context in conjunction with useReducer. In this case, the result of the useReducer hook - the returned state and the function for updating it - will be passed to the context. 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";
//
export const initialState: State = {
newTask: '',
tasks: []
}
// <Partial>
export const ContextApp = React.createContext<Partial<ContextState>>({});
// , Action type payload, - State
export 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 = () => {
// todoReducer, useReduser. initialState, (changeState) .
const [state, changeState] = useReducer<React.Reducer<State, Action>>(todoReducer, initialState);
const ContextState: ContextState = {
state,
changeState
};
// useReducer -
return (
<>
<ContextApp.Provider value={ContextState}>
<NewTask />
<TasksList />
</ContextApp.Provider>
</>
)
}
export default App;
As a result, we managed to make a state independent of the root component, which can be received and changed in components within the provider.
Typescript. Adding types to the application
In the stateType file, we write the TypeScript types for the application:
import {Dispatch} from "react";
//
export type Task = {
name: string;
isDone: boolean
}
export type Tasks = Task[];
// ,
export type State = {
newTask: string;
tasks: Tasks
}
//
export enum ActionType {
ADD = 'ADD',
CHANGE = 'CHANGE',
REMOVE = 'REMOVE',
TOGGLE = 'TOGGLE'
}
// ADD CHANGE
type ActionStringPayload = {
type: ActionType.ADD | ActionType.CHANGE,
payload: string
}
// TOGGLE REMOVE Task
type ActionObjectPayload = {
type: ActionType.TOGGLE | ActionType.REMOVE,
payload: Task
}
//
export type Action = ActionStringPayload | ActionObjectPayload;
// -, Action. Dispatch react
export type ContextState = {
state: State;
changeState: Dispatch<Action>
}
Using context
Now state is 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 = () => {
// state dispatch-
const {state, changeState} = useContext(ContextApp);
// todoReducer - . state . React-
const addTask = (event: React.FormEvent<HTMLFormElement>, task: TaskName) => {
event.preventDefault();
changeState({type: ActionType.ADD, payload: task})
changeState({type: ActionType.CHANGE, payload: ''})
}
// -
const 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 = () => {
// ( 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! It remains to test it.
Testing
For testing, Jest + Enzyme will be used as well as @ testing-library / react .
You need to install dev dependencies:
"@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",
Add settings for jest to package.json:
"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 for running tests:
"test": "jest"
Create a new __tests__ directory in the src directory and in it a setup.ts file with the following content:
import {configure} from 'enzyme';
import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';
const adapter = ReactSixteenAdapter as any;
configure({ adapter: new adapter() });
Let's create a file todoReducer.test.ts, in which we will 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', () => {
//
const initialState: State = {newTask: '', tasks: []};
// 'ADD' 'new task'
const updateAction: Action = {type: ActionType.ADD, payload: 'new task'};
//
const updatedState = todoReducer(initialState, updateAction);
//
expect(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 the reducer, it is enough to pass the current state and action to it, and then catch the result of its execution.
Testing the App.tsx component, in contrast to the reducer, requires the use of additional methods from different libraries. 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 />', () => {
// jest- afterEach cleanup
afterEach(cleanup);
it('hasn`t got changes', () => {
// shallow enzyme -, .
const component = shallow(<App />);
// . . snapshots -u: jest -u
expect(component).toMatchSnapshot();
});
// ( DOM-), async
it('should render right input value', async () => {
// render() @testing-library/react" shallow() , DOM- . container β div, .
const { container } = render(<App/>);
expect(container.querySelector('input').getAttribute('value')).toEqual('');
// 'test'
fireEvent.change(container.querySelector('input'), {
target: {
value: 'test'
},
})
// 'test'
expect(container.querySelector('input').getAttribute('value')).toEqual('test');
// .
fireEvent.click(container.querySelector('button'))
// value
expect(container.querySelector('input').getAttribute('value')).toEqual('');
});
})
In the TasksList component, check if the passed state is displayed correctly. TasksList.test.tsx file:
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);
//
const testState: State = {
newTask: '',
tasks: [{name: 'test', isDone: false}, {name: 'test2', isDone: false}]
}
// ContextApp
const Wrapper = () => {
return (
<ContextApp.Provider value={{state: testState}}>
<TasksList/>
</ContextApp.Provider>
)
}
it('should render right tasks length', async () => {
const {container} = render(<Wrapper/>);
//
expect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length);
});
})
A similar check of the newTask field can be done for the NewTask component by checking the value of the input element.
The project can be downloaded from the GitHub repository .
That's all, thanks for your attention.
Resources
React JS. Hooks
Working with React Hooks and TypeScript