Начиная с версии 16.9, в библиотеке React JS доступен новый функционал — хуки. Они дают возможность использовать состояние и другие функции React, освобождая от необходимости писать класс. Использование функциональных компонентов совместно с хуками позволяет разработать полноценное клиентское приложение.
Предлагаю рассмотреть создание версии Todolist приложения на React Hooks с использованием TypeScript.
Структура проекта следующая:
├── src
| ├── components
| ├── index.html
| ├── index.tsx
├── package.json
├── tsconfig.json
├── webpack.config.json
В файле index.html прописываем контейнер, куда будет рендериться приложение:
В директории components создаем наш первый пока что пустой компонент — App.tsx.
Файл index.tsx:
Todolist-приложение будет иметь следующую функциональность:
Выглядеть это будет так: текстовое поле для ввода + кнопка Добавить задачу, и ниже — список добавленных задач. Задачи можно удалять и менять им статус.
Для этих целей можно разделить приложение всего на два компонента — создание новой задачи и список всех задач. Поэтому App.tsx на начальном этапе будет иметь следующий вид:
В текущей директории создадим и экспортируем пустые компоненты NewTask и TasksList. Так как нам необходимо обеспечить взаимосвязь между ними, нужно определить, как это будет происходить. В React существуют два подхода к взаимодействию между компонентами:
Мы будем использовать второй способ и в данном примере полностью откажемся от пропсов.
Итак, для передачи стейта воспользуемся хуком useContext. Он позволяет получать и изменять данные в любом из компонентов, обернутых провайдером.
Также понадобится useReducer для более удобной работы с хранилищем состояния.
Интересной заменой библиотеки Redux может быть использование контекста в связке с useReducer. В этом случае в контекст будет передаваться результат выполнения хука useReducer — возвращаемый им стейт и функция для его обновления. Добавим эти хуки в приложение:
В результате удалось сделать независимый от корневого компонента стейт, который можно получать и менять в компонентах внутри провайдера.
В файле stateType прописываем TypeScript-типы для приложения:
Теперь state готов и может быть использован в компонентах. Начнём с NewTask.tsx:
TasksList.tsx:
Приложение готово! Осталось протестировать его.
Для тестирования будут использоваться Jest + Enzyme, а также @testing-library/react.
Необходимо установить dev-зависимости:
В package.json добавляем настройки для jest:
и в блоке «scripts» добавляем скрипт запуска тестов:
Создаём в директории src новый каталог __tests__ и в нем — файл setup.ts с таким содержимым:
Создадим файл todoReducer.test.ts, в котором протестируем редьюсер:
Для тестирования редьюсера достаточно передать ему текущий стейт и действие, и затем ловить результат его выполнения.
Тестирование компонента App.tsx, в отличие от редьюсера, требует использования дополнительных методов из разных библиотек. Тестовый файл App.test.tsx:
В TasksList компоненте проверим, правильно ли отображается передаваемый стейт. Файл TasksList.test.tsx:
Аналогичную проверку поля newTask можно сделать для компонента NewTask, проверяя value у элемента input.
Проект можно скачать с GitHub-репозитория.
На этом всё, спасибо за внимание.
React JS. Хуки
Working with React Hooks and TypeScript
Предлагаю рассмотреть создание версии Todolist приложения на React Hooks с использованием TypeScript.
Сборка
Структура проекта следующая:
├── src
| ├── components
| ├── index.html
| ├── index.tsx
├── package.json
├── tsconfig.json
├── webpack.config.json
Файл package.json:
Для поддержки 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:
Поле «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:
Точка входа приложения — ./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. Сборка готова.
Разработка
В файле index.html прописываем контейнер, куда будет рендериться приложение:
<div id="root"></div>
В директории components создаем наш первый пока что пустой компонент — App.tsx.
Файл index.tsx:
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from "./components/App";
ReactDOM.render (
<App/>,
document.getElementById("root")
);
Todolist-приложение будет иметь следующую функциональность:
- добавить задачу
- удалить задачу
- изменить статус задачи (выполнена / не выполнена)
Выглядеть это будет так: текстовое поле для ввода + кнопка Добавить задачу, и ниже — список добавленных задач. Задачи можно удалять и менять им статус.
Для этих целей можно разделить приложение всего на два компонента — создание новой задачи и список всех задач. Поэтому App.tsx на начальном этапе будет иметь следующий вид:
import * as React from 'react';
import NewTask from "./NewTask";
import TasksList from "./TasksList";
const App = () => {
return (
<>
<NewTask />
<TasksList />
</>
)
}
export default App;
В текущей директории создадим и экспортируем пустые компоненты NewTask и TasksList. Так как нам необходимо обеспечить взаимосвязь между ними, нужно определить, как это будет происходить. В React существуют два подхода к взаимодействию между компонентами:
- Хранение текущего состояния приложения и всех его методов в родительском компоненте (в нашем случае — в App.tsx) и передача дочерним компонентам через пропсы (классический способ);
- Хранение состояния и методов управления состоянием отдельно. В этом случае приложение нужно обернуть специальным компонентом — провайдером, и передать в него необходимые для дочерних компонентов методы и свойства (использование хука useContext).
Мы будем использовать второй способ и в данном примере полностью откажемся от пропсов.
TypeScript при передаче пропсов
* Если в компонент всё же передаются пропсы, TypeScript потребует явного указания типа для компонента:
Тип React.FC, являясь дженериком, ожидает получить интерфейс (или тип) для переданных родительским компонентом параметров:
const NewTask: React.FC<MyProps> = ({taskName}) => {...
Тип React.FC, являясь дженериком, ожидает получить интерфейс (или тип) для переданных родительским компонентом параметров:
interface MyProps {
taskName: String;
}
useContext
Итак, для передачи стейта воспользуемся хуком useContext. Он позволяет получать и изменять данные в любом из компонентов, обернутых провайдером.
Пример использования useContext
В примере создаём интерфейс для контекста — будем передавать поля 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
Также понадобится useReducer для более удобной работы с хранилищем состояния.
Подробнее о 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
Интересной заменой библиотеки Redux может быть использование контекста в связке с useReducer. В этом случае в контекст будет передаваться результат выполнения хука useReducer — возвращаемый им стейт и функция для его обновления. Добавим эти хуки в приложение:
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;
В результате удалось сделать независимый от корневого компонента стейт, который можно получать и менять в компонентах внутри провайдера.
Typescript. Добавление типов в приложение
В файле stateType прописываем TypeScript-типы для приложения:
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>
}
Использование контекста
Теперь state готов и может быть использован в компонентах. Начнём с 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;
Приложение готово! Осталось протестировать его.
Тестирование
Для тестирования будут использоваться Jest + Enzyme, а также @testing-library/react.
Необходимо установить dev-зависимости:
"@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",
В package.json добавляем настройки для 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"
]
},
и в блоке «scripts» добавляем скрипт запуска тестов:
"test": "jest"
Создаём в директории src новый каталог __tests__ и в нем — файл setup.ts с таким содержимым:
import {configure} from 'enzyme';
import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';
const adapter = ReactSixteenAdapter as any;
configure({ adapter: new adapter() });
Создадим файл todoReducer.test.ts, в котором протестируем редьюсер:
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: []});
});
})
Для тестирования редьюсера достаточно передать ему текущий стейт и действие, и затем ловить результат его выполнения.
Тестирование компонента App.tsx, в отличие от редьюсера, требует использования дополнительных методов из разных библиотек. Тестовый файл 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('');
});
})
В TasksList компоненте проверим, правильно ли отображается передаваемый стейт. Файл 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);
// Создаём тестовый стейт
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);
});
})
Аналогичную проверку поля newTask можно сделать для компонента NewTask, проверяя value у элемента input.
Проект можно скачать с GitHub-репозитория.
На этом всё, спасибо за внимание.
Ресурсы
React JS. Хуки
Working with React Hooks and TypeScript