🔢 Делаем калькулятор на React c хуками и Context API

Идея для проекта выходного дня – собираем калькулятор на React Hooks и Context API. Осваиваем эти интересные технологии в деле. Приводим пример возможной реализации проекта.

Не бывает лучше проекта в портфолио, чем тот, что можно оценить в действии. Через тернии – к звёздам! Показываем пример простого калькулятора на React с использованием современных технологий. Проект можно и нужно улучшать. Текущий результат – кликабельная задеплоенная демка (код на GitHub). Ниже некоторые пояснения о выбранных решениях.

Внешний вид калькулятора

Приступим!

Для начала запустим стандартный create-react-app:

npx create-react-app calculator
cd calculator
npm start

Файловая структура и CSS

Файловая структура рассматриваемого приложения выглядит следующим образом:

src
├── App.js
├── index.js
└── components
    ├── BackButton.js
    ├── Calculator.js
    ├── ClearButton.js
    ├── Display.js
    ├── EqualButton.js
    ├── FunctionButton.js
    ├── NegativeButton.js
    ├── NumberButton.js
    ├── NumberProvider.js
    └── styles
        └── Styles.js

Если вы хотите точно следовать примеру, нужно установить Styled Components для CSS:

npm -i styled-components

Затем необходимо добавить Styled CSS в Styles.js.

Основная структура

Файл Calculator.js задает настройки экрана и клавиатуры. Он содержит следующий код:

Calculator.js
import React from 'react';
import NumberButton from './NumberButton';
import FunctionButton from './FunctionButton';
import ClearButton from './ClearButton';
import Display from './Display';
import EqualButton from './EqualButton';
import BackButton from './BackButton';
import NegativeButton from './NegativeButton';
import { CalculatorStyles } from './styles/Styles';

const Calculator = () => (
  <CalculatorStyles>
    <div className='display'>
      <h1>CALC-U-LATER</h1>
      <Display />
    </div>
    <div className='number-pad'>
      <ClearButton />
      <BackButton />
      <NegativeButton />
      <FunctionButton buttonValue='/' />
      <NumberButton buttonValue={7} />
      <NumberButton buttonValue={8} />
      <NumberButton buttonValue={9} />
      <FunctionButton buttonValue='*' />
      <NumberButton buttonValue={4} />
      <NumberButton buttonValue={5} />
      <NumberButton buttonValue={6} />
      <FunctionButton buttonValue='-' />
      <NumberButton buttonValue={1} />
      <NumberButton buttonValue={2} />
      <NumberButton buttonValue={3} />
      <FunctionButton buttonValue='+' />
      <div className='zero-button'>
        <NumberButton buttonValue={0} />
      </div>
      <NumberButton buttonValue='.' />
      <EqualButton />
    </div>
  </CalculatorStyles>
);

export default Calculator;

Каждый компонент кнопок следует одной базовой структуре. Нулевая кнопка лежит в отдельном div-блоке, так как мы используем для макета грид.

Кстати, о гридах
Если хотите узнать больше о CSS Grid, мы написали несколько статей на эту тему.

Обратите внимание, что свойство buttonValue необходимо только для NumberButton и FunctionButton.

Создание Context API Provider

NumberProvider.js – это сердце приложения и место, где наши функции обретают жизнь. Здесь же используется React Context API – отличный инструмент передачи данных между компонентами.

Чтобы передать данные или функции через вложенную структуру компонентов, пришлось бы попотеть. Обертка из компонента провайдера позволяет передавать данные в любой вложенный компонент независимо от глубины вложенности. Всякий раз, когда нужно получить данные или использовать функцию, находящуюся внутри провайдера, она будет доступна глобально.

Предварительная версия NumberProvider.js
import React from 'react';

export const NumberContext = React.createContext();

const NumberProvider = (props) => {
  const number = '0';
  return (
    <NumberContext.Provider
      value={{
        number,
      }}>
      {props.children}
    </NumberContext.Provider>
  );
};

export default NumberProvider;

Любое переданное значение теперь доступно всем вложенным компонентам. Так что есть всё необходимое для заполнения App.js:

App.js
import React from 'react';
import Calculator from './components/Calculator';
import NumberProvider from './components/NumberProvider';

const App = () => (
  <NumberProvider>
    <Calculator />
  </NumberProvider>
);

export default App;

Используем Context Provider

Теперь добавим код для дисплея. Можно выводить значение, передав его в функцию useContext из нового React Hooks API. Больше не нужно передавать проп через вложенные компоненты.

Предварительная версия Display.js
import React, { useContext } from 'react';
import { NumberContext } from './NumberProvider';
import { DisplayStyles } from './styles/Styles';

const Display = () => {
  const { number } = useContext(NumberContext);
  return (
    <DisplayStyles>
      <h2>{number}</h2>
      <p>Enter Some Numbers</p>
    </DisplayStyles>
  );
};

export default Display;

Число, которое передано на три уровня выше в NumberProvider, становится сразу же доступным компоненту Display при вызове useContext и передаче созданного нами NumberContext. Цифровой дисплей теперь включен, работает и отображает number, исходно приравненное нулю.

Начинаем работать с Hooks

Хуки позволяют «облегчить» синтаксис класса и получить состояние внутри функциональных компонентов. Добавим следующий код в NumberProvider.js, чтобы создать хук:

NumberProvider.js
import React, { useState } from 'react';

export const NumberContext = React.createContext();

const NumberProvider = (props) => {
  const [number, setNumber] = useState('');

  const handleSetDisplayValue = (num) => {
    if (!number.includes('.') || num !== '.') {
      setNumber(`${(number + num).replace(/^0+/, '')}`);
    }
  };

  return (
    <NumberContext.Provider
      value={{
        handleSetDisplayValue,
        number,
      }}>
      {props.children}
    </NumberContext.Provider>
  );
};

export default NumberProvider;

Вместо написания класса с состоянием мы разбиваем состояние и переносим каждую его часть в переменную number. Здесь же используется setNumber, действующая, как функция setState. Для инициализации используется useState.

Создание компонента кнопки

Теперь мы можем вызвать функцию с помощью Context API в любом из вложенных компонентов.

NumberButton.js
import React, { useContext } from 'react';
import { NumberContext } from './NumberProvider';

const NumberButton = ({ buttonValue }) => {
  const { handleSetDisplayValue } = useContext(NumberContext);
  return (
    <button type='button' onClick={() => handleSetDisplayValue(buttonValue)}>
      {buttonValue}
    </button>
  );
};

export default NumberButton;

Обратите внимание, как можно начать вводить значения, заданные в NumberProvider, в другие компоненты приложения с помощью функции useContext. Состояние и функции, влияющие на них, хранятся в NumberProvider . Просто вызываем определенный контекст – и готово.

Функции провайдера

Завершенный NumberProvider.js находится ниже и содержит следующие функции, которые используются вместе с хуками:

  • handleSetDisplayValue: задает значение, выводимое на дисплей. Мы проверяем, что в числовой строке есть только один десятичный знак, и ограничиваем длину числа 8 символами. Он передает свойство buttonValue в NumberButton.js.
  • handleSetStoredValue: принимает строку и сохраняет ее, позволяя ввести другое число.
  • handleClearValue: сбрасывает всё в 0. Это «функция очистки», которая передается в ClearButton.js.
  • handleBackButton: позволяет удалять ранее введенные символы по одному, пока вы не вернетесь в 0. Код привязан к BackButton.js.
  • handleSetCalcFunction: срабатывает при выборе математической функции, передается в FunctionButton.js и в свойства buttonValue.
  • handleToggleNegative: оперирует отображаемыми или сохраненными значениями, передаваемыми в NegativeButton.js.
  • doMath: запускает выбранную математическую операцию.
NumberProvider.js
import React, { useState } from 'react';

export const NumberContext = React.createContext();

const NumberProvider = (props) => {
  const [number, setNumber] = useState('');
  const [storedNumber, setStoredNumber] = useState('');
  const [functionType, setFunctionType] = useState('');

  const handleSetDisplayValue = (num) => {
    if ((!number.includes('.') || num !== '.') && number.length < 8) {
      setNumber(`${(number + num).replace(/^0+/, '')}`);
    }
  };

  const handleSetStoredValue = () => {
    setStoredNumber(number);
    setNumber('');
  };

  const handleClearValue = () => {
    setNumber('');
    setStoredNumber('');
    setFunctionType('');
  };

  const handleBackButton = () => {
    if (number !== '') {
      const deletedNumber = number.slice(0, number.length - 1);
      setNumber(deletedNumber);
    }
  };

  const handleSetCalcFunction = (type) => {
    if (number) {
      setFunctionType(type);
      handleSetStoredValue();
    }
    if (storedNumber) {
      setFunctionType(type);
    }
  };

  const handleToggleNegative = () => {
    if (number) {
      if (number > 0) {
        setNumber(`-${number}`);
      } else {
        const positiveNumber = number.slice(1);
        setNumber(positiveNumber);
      }
    } else if (storedNumber > 0) {
      setStoredNumber(`-${storedNumber}`);
    } else {
      const positiveNumber = storedNumber.slice(1);
      setStoredNumber(positiveNumber);
    }
  };

  const doMath = () => {
    if (number && storedNumber) {
      switch (functionType) {
        case '+':
          setStoredNumber(
            `${Math.round(`${(parseFloat(storedNumber) + parseFloat(number)) * 100}`) / 100}`
          );
          break;
        case '-':
          setStoredNumber(
            `${Math.round(`${(parseFloat(storedNumber) - parseFloat(number)) * 1000}`) / 1000}`
          );
          break;
        case '/':
          setStoredNumber(
            `${Math.round(`${(parseFloat(storedNumber) / parseFloat(number)) * 1000}`) / 1000}`
          );
          break;
        case '*':
          setStoredNumber(
            `${Math.round(`${parseFloat(storedNumber) * parseFloat(number) * 1000}`) / 1000}`
          );
          break;
        default:
          break;
      }
      setNumber('');
    }
  };

  return (
    <NumberContext.Provider
      value={{
        doMath,
        functionType,
        handleBackButton,
        handleClearValue,
        handleSetCalcFunction,
        handleSetDisplayValue,
        handleSetStoredValue,
        handleToggleNegative,
        number,
        storedNumber,
        setNumber,
      }}>
      {props.children}
    </NumberContext.Provider>
  );
};

export default NumberProvider;

Итоговое представление экрана

Обновим файл для отображения экрана. Он должен показывать number и storedNumber в соответствии с functionType. Еще есть несколько проверок, таких как отображение 0 при вставке пустой строки вместо числа.

Display.js
import React, { useContext } from 'react';
import { NumberContext } from './NumberProvider';
import { DisplayStyles } from './styles/Styles';

const Display = () => {
  const { number, storedNumber, functionType } = useContext(NumberContext);
  return (
    <DisplayStyles>
      <h2>{!number.length && !storedNumber ? '0' : number || storedNumber}</h2>
      <p>{!storedNumber ? 'ENTER SOME NUMBERS' : `${storedNumber} ${functionType} ${number}`}</p>
    </DisplayStyles>
  );
};

export default Display;

Заключение

Библиотека программиста надеется, что данный материал немного прояснит вопрос о том, как можно использовать хуки React вместе с Context API. Использование встроенных функций React дает ряд преимуществ:

  • простой для понимания синтаксис и отсутствие беспорядка в компонентах класса. Больше никаких super и constructor – просто чистые переменные;
  • проще устанавливать и использовать состояние внутри компонентов и между ними;
  • нет необходимости в Redux для небольших проектов.

React Hooks и Context API – отличные способы упростить приложения React и писать более чистый код. О других технологиях React читайте в нашем руководстве для React-разработчика.

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ