Авторский кредит: SHUHEB AHMED
В этом посте мы создадим приложение для повышения продуктивности, используя React Hooks и AG Grid. Мы расскажем о том, как React Hooks используются для создания этого приложения и конкретно AG Grid. Вы можете увидеть готовое приложение Pomodoro App в действии, размещенное здесь.
Вы можете скачать исходный код на Github и посмотреть, как построено приложение, чтобы лучше следить за обсуждением ниже.
Что такое React Hooks?
React Hooks – это функции, предоставляемые React, которые позволяют компонентам напрямую “подключаться” к функциям React (например, иметь переменную состояния, обращаться к контексту) без написания класса для этой цели. React Hooks следуют соглашению об именовании с префиксом use
.
Приложение Pomodoro App использует следующие React Hooks:
Чтобы прочитать о React Hooks более подробно, посетите официальные документы React Docs и React Docs (beta).
Обзор исходного кода
Ниже приведен обзор структуры кодовой базы:
ag-grid-pomodoro
├── src
│ ├── components
│ │ ├── cell-renderers
│ │ │ ├── ActionCellRenderer.js
│ │ │ └── ProgressCellRenderer.js
│ │ ├── full-width-cell-renderers
│ │ │ └── AddTaskCellRenderer.js
│ │ ├── task-components
│ │ │ ├── TaskType.js
│ │ │ ├── TaskDetails.js
│ │ │ ├── TaskTimer.js
│ │ │ └── EndTime.js
│ │ ├── MainTask.js
│ │ ├── PomodoroGrid.js
│ │ └── SaveButton.js
│ ├── context
│ │ └── PomodoroContext.js
│ ├── reducers
│ │ └── reducers.js
│ ├── utils
│ │ ├── useTimer.js
│ │ └── date.js
│ ├── App.css
│ ├── App.js
│ └── index.js
├── README.md
└── package.json
Код приложения находится в директории /src/
. Ниже приведены ключевые файлы, содержащие важные компоненты приложения:
Обзор приложения
Теперь давайте рассмотрим, как работает приложение. Ниже представлен визуальный обзор пользовательского интерфейса приложения, показывающий три компонента (MainTask
, PomodoroGrid
и SaveButton
), из которых оно состоит:
Обзор приложения
Компонент приложения определен, как показано ниже:
const App = () => {
// [...]
return (
<>
<PomodoroProvider>
<MainTask />
<PomodoroGrid />
<SaveButton />
</PomodoroProvider>
</>
);
}
Состояние приложения хранится вне App
и разделяется между его компонентами MainTask
и PomodoroGrid
.
Переменная state – это объект, который хранит массив tasks
и activeTaskId
для хранения ID задачи, которая в настоящее время активна, т.е. таймер был запущен для этой задачи. См. объявление переменной состояния ниже:
const state = {
tasks: [],
activeTaskId: -1
}
Вот диаграмма, показывающая, как это работает – обратите внимание, что MainTask
и PomodoroGrid
имеют доступ к общей переменной состояния, которую они оба могут читать и обновлять. Реализация состояния и то, как App
взаимодействует с ним, рассматривается далее в разделе Управление состоянием с помощью useContext и useReducer.
Приложение может читать и обновлять общее состояние
Компонент MainTask
Этот компонент отображает группу кнопок для переключения между различными типами задач: pomodoro, короткий перерыв или длительный перерыв. Компонент также отображает таймер с кнопкой для переключения таймера. MainTask
может читать из общего состояния, где хранятся задачи, так что если выбрана задача из PomodoroGrid
, прогресс таймера и детали задачи этой задачи будут показаны внутри компонента MainTask
.
Вы можете увидеть это в приведенном ниже GIF. Обратите внимание, что после нажатия кнопки “Старт” на задаче “Написать черновик блога” в сетке ниже, название задачи отображается в компоненте MainTask
выше, а таймер начинает тикать:
Задача отображается внутри компонента MainTask
Компонент PomodoroGrid
PomodoroGrid
отображает элемент AG Grid с каждой строкой внутри сетки, представляющей задачу. Подобно MainTask
, компонент сетки может читать и обновлять общее состояние, где хранятся задачи, которое определено вне компонента PomodoroGrid
.
Каждая строка сетки имеет три кнопки – (1) для переключения таймера, (2) для отметки задачи как выполненной и (3) для удаления задачи. Эти кнопки отображаются в столбце Action
сетки.
Название задачи отображается в столбце Task
.
В строке также отображается таймер в столбце Progress
, ячейки которого отрисовываются с помощью ProgressCellRenderer
.
В нижней части сетки есть прикрепленная строка, которая используется для добавления задач. Когда задача добавляется, состояние приложения (в котором хранятся все задачи) обновляется новой задачей, после чего сетка перерисовывается, отображая новую добавленную задачу.
Посмотрите на это в действии ниже:
Добавление задачи
Реализация этого процесса более подробно описана в следующем разделе.
Управление состоянием с помощью useContext и useReducer
Как упоминалось в предыдущих разделах, мы управляем состоянием вне PomodoroGrid
и MainTask
, чтобы оба компонента могли совместно использовать одни и те же данные и обновлять их при выполнении действия.
На диаграмме ниже показан обзор того, как происходит обмен и обновление состояния.
Обзор магазина
Следующие действия обновляют состояние:
- Добавление задачи
- завершение задачи
- Переключение таймера задачи
- удаление задачи
Чтобы обновить состояние на основе этих действий, мы используем хук useReducer
, как описано ниже.
Reducer
Хук React useReducer
позволяет вам обновлять текущее состояние путем диспетчеризации действий.
Редукторы – это чистые функции, которые получают текущее состояние приложения вместе с действием, которое должно быть выполнено над этим состоянием для получения нового состояния. По сути, редукторы можно представить как машину состояния, которая имеет некоторое начальное состояние и обновляет его на основе действия.
Вот пример того, как это можно определить:
const initialState = {
tasks: [],
activeTaskId: -1
};
const reducer = (state = {}, action) => {
switch (action.type) {
case 'added_task':
return {
...state,
tasks: [...state.tasks, {
id: action.id,
task: action.task,
taskNo: action.taskNo,
taskCount: action.taskCount,
}]
}
// ...
default:
return state;
}
}
function MyComponent() {
const [state, dispatch] = useReducer(reducer, initialState);
}
Как вы видите, хук useReducer
возвращает кортеж из текущего state
и метода dispatch
, который используется для обновления состояния.
Действия
Действия описывают операцию, которую редуктор должен выполнить над состоянием. Например, действие для добавления новой задачи может выглядеть следующим образом:
const addTask = {
type: 'added_task',
id: generateId(),
task: 'pick up groceries',
taskNo: 1,
taskCount: 1
};
Используя метод dispatch
, мы отправляем действие на reducer
, который преобразует состояние.
В нашем приложении мы вызываем dispatch
при нажатии на кнопку.
Вот код для диспетчеризации addTask
, определенный выше:
function MyComponent() {
const [state, dispatch] = useReducer(reducer, initialState);
const addTask = {
type: 'added_task',
id: generateId(),
task: 'pick up groceries',
taskNo: 1,
taskCount: 1
};
// this would be called from a button click
const addTaskHandler = () => {
dispatch(addTask);
}
}
Контекст
React Context позволяет вам обмениваться данными между компонентами React без необходимости вручную передавать их в качестве реквизитов каждому компоненту.
Чтобы поделиться state
и dispatch
в PomodoroGrid
и MainTask
, мы добавляем их в React Context, чтобы оба компонента могли обновлять состояние, когда это необходимо.
Контекст определяется следующим образом:
import { createContext } from 'react';
export const PomodoroContext = createContext();
Теперь, когда мы создали PomodoroContext
для хранения наших общих данных, следующим шагом будет создание компонента для обертывания приложения, который будет предоставлять контекст оттуда:
// src/context/PomodoroContext.js
import reducer from "../reducers/reducer";
// initial state
const gridState = {
tasks: [],
activeTaskId: -1
};
export const PomodoroProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, gridState);
const { tasks, activeTaskId } = state;
// [...]
const value = {tasks, activeTaskId, dispatch}
return (<PomodoroContext.Provider value={actions}>
{children}
</PomodoroContext.Provider>
);
}
Компонент-обертка PomodoroProvider
определяет хук useReducer
для хранения state
и метода dispatch
. Компонент возвращает PomodoroContext.Provider
и имеет свойство value
, которое инициализирует PomodoroContext
с task
, activeTaskId
и dispatch
. В результате, любой компонент, который рендерится внутри PomodoroProvider
, может получать tasks
, activeTaskId
и dispatch
.
Компонент-обертка определяется вокруг всего приложения, что можно увидеть в приведенном ниже фрагменте. Обратите внимание, что MainTask
, PomodoroGrid
и SaveButton
обернуты внутри PomodoroProvider
, что означает, что они будут иметь доступ к tasks
, activeTaskId
и dispatch
из PomodoroContext
.
// src/App.js
import { PomodoroProvider } from './context/PomodoroContext';
import MainTask from './components/MainTask';
import SaveButton from './components/SaveButton';
import PomorodoGrid from './components/PomodoroGrid';
const App = () => {
// [...]
return (
<>
<PomodoroProvider>
<MainTask />
<PomodoroGrid />
<SaveButton />
</PomodoroProvider>
</>
);
}
export default App;
Теперь, когда компоненту нужен доступ к хранилищу, он может читать из PomodoroContext
и получать tasks
, activeTaskId
и dispatch
.
Например, компонент Grid может получить данные для отображения в виде строк из tasks
. Ему не нужен доступ к dispatch
или activeTaskId
, поэтому они не извлекаются из контекста:
// src/components/PomodoroGrid.js
import React, { useContext } from 'react';
import { PomodoroContext } from '../context/PomodoroContext';
const PomodoroGrid = props => {
const { tasks } = useContext(PomodoroContext);
// [...]
return (
<div style={{ height: '50%', width: '100%' }}>
<AgGridReact
rowData={tasks}
// [...]
>
</AgGridReact>
</div>
);
}
Чтобы увидеть это в действии, посмотрите следующий GIF. Обратите внимание, что мы можем переключать таймер как из MainTask
, так и из PomodoroGrid
в дополнение к тому, что MainTask
показывает детали активной задачи.
Обратите внимание, как MainTask показывает активную задачу
Создание пользовательского крючка с использованием useState и useEffect
Приложение pomodoro отображает таймер в MainTask
и в колонке Progress
каждой строки внутри PomodoroGrid
.
В GIF ниже показано, как работает таймер – обратите внимание, как синхронизируются таймеры на MainTask
и в столбце Progress
при запуске задачи:
useTimer hook ticking
Логика, обрабатывающая тиканье таймера, может быть извлечена в пользовательский хук, поскольку она повторно используется для обоих компонентов. Имя этого пользовательского хука в исходном коде – useTimer
.
Хук useTimer
принимает три параметра:
Пользовательский хук useTimer
определяется следующим образом:
const useTimer = (timerStarted, initialSeconds, taskCompletedCallback) => {
// [...]
};
Мы определили хук useState
seconds
для хранения времени, оставшегося на таймере. Он инициализируется с помощью initialSeconds
, как показано ниже:
const useTimer = (timerStarted, initialSeconds, taskCompletedCallback) => {
const [seconds, setSeconds] = useState(initialSeconds);
// [...]
return [seconds, setSeconds];
};
Кортеж seconds
и setSeconds
возвращается useTimer
, чтобы компоненты, использующие useTimer
, могли получить seconds
.
Для обработки тиканья таймера мы создали хук useEffect
, где seconds
уменьшается каждую секунду, пока таймер не остановится или seconds
не достигнет нуля, в этом случае вызывается taskCompletedCallback
:
// src/utils/useTimer.js
import { useEffect, useState } from "react";
const useTimer = (timerStarted, initialSeconds, taskCompletedCallback) => {
const [seconds, setSeconds] = useState(initialSeconds);
useEffect(() => {
let timer;
if (timerStarted) {
if (seconds === 0) {
taskCompletedCallback()
} else if (seconds > 0) {
timer = setInterval(() => {
setSeconds(seconds - 1)
}, 1000);
}
}
return () => {
if (timer) { clearInterval(timer); };
}
}, [timerStarted, seconds, taskCompletedCallback]);
return [seconds, setSeconds];
};
export default useTimer;
Компонент пользовательского рендеринга ячеек сетки ProgressCellRenderer
использует хук useTimer
, как показано ниже:
const ProgressCellRenderer = memo(props => {
const { dispatch, activeTaskId } = useContext(PomodoroContext);
const { id, timerStarted, timeLeft } = props.node.data;
const taskCompletedCallback = useCallback(() => {
dispatch({ type: 'completed_task', id })
}, [id, dispatch]);
const [seconds] = useTimer(timerStarted, timeLeft, taskCompletedCallback);
let timeString = formatSecondsIntoMinutesAndSeconds(seconds);
return (<>
<div>
{timeString}
</div>
</>)
});
В этом случае taskCompletedCallback
отправляет действие completed_task
, когда оно вызывается, что и приводит к тому, что строка имеет зеленый фон в GIF, показанном выше.
Доступ к Grid API с помощью useRef
Хук useRef
позволяет нам получить ссылку на api
и columnApi
AG Grid, передав ее свойству ref
в AgGridReact
.
В нашем приложении SaveButton
отображает кнопку, которая при нажатии сохраняет текущее состояние в локальное хранилище. Мы используем Grid API для вызова api.showLoadingOverlay()
, чтобы уведомить пользователя, что он не может выполнить действие, если активна задача.
Посмотрите на это в действии в следующем GIF, обратите внимание, как таймер работает, пока кнопка нажата, что приводит к появлению накладки:
Вызов Grid API из SaveButton и сохранение состояния в локальном хранилище
Поскольку SaveButton
и PomodoroGrid
являются родственными компонентами, мы должны определить переменную useRef
на родительском App
и передать ее обоим компонентам.
// src/App.js
const App = () => {
const gridRef = useRef(null);
// [...]
return (
<>
<PomodoroProvider>
<MainTaskComponent />
<Grid gridRef={gridRef} />
<SaveButton gridRef={gridRef} />
</PomodoroProvider>
</>
);
}
PomodoroGrid
получает useRef
hook gridRef
как props
, который затем инициализируется путем передачи в ref
AG Grid:
// src/components/PomodoroGrid.js
const PomodoroGrid = props => {
// [...]
return (
<div style={{ height: '50%', width: '100%' }}>
<AgGridReact
ref={props.gridRef}
// [...]
>
</AgGridReact>
</div>
);
}
После того, как PomodoroGrid
инициализирует gridRef
с API Grid, мы можем получить доступ к методам API из SaveButton
, чтобы сохранить список задач в локальном хранилище:
// src/components/SaveButton.js
const SaveButton = props => {
const { tasks, activeTaskId } = useContext(PomodoroContext);
const { gridRef } = props;
const saveHandler = () => {
if (activeTaskId) {
let activeTask = tasks.filter(row => row.id === activeTaskId);
if (activeTask.length > 0) {
if (activeTask[0].timerStarted) {
gridRef.current.api.showLoadingOverlay();
setTimeout(() => {
gridRef.current.api.hideOverlay();
}, 3000);
return;
}
}
}
localStorage.setItem('gridState', JSON.stringify({ tasks, activeTaskId }));
alert('Saved Grid State to Local Storage');
}
return (<div>
<Button
// [...]
onClick={saveHandler}
>
Save to Local Storage
</Button>
</div>
)
})
Резюме
Мы надеемся, что эта статья окажется полезной при использовании AG Grid с React Hooks. Не стесняйтесь форкать пример из этого git-репозитория и модифицировать его в соответствии с вашими потребностями.
Если вы хотите попробовать AG Grid, ознакомьтесь с нашими руководствами по началу работы (JS / React / Angular / Vue).