Omawiamy projekt kalkulatora w React znaleziony na GitHubie. Świetna okazja, aby poznać lepiej useReducer i ogarnąć zaawansowane zarządzanie stanem. Do dzieła!

Ok, zanim przejdziemy do reducera, a jest co omawiać, rzućmy okiem na te helpery czy ogarniamy co i jak:

function evaluate({ currentOperand, previousOperand, operation }) {
  const prev = parseFloat(previousOperand)
  const current = parseFloat(currentOperand)
  if (isNaN(prev) || isNaN(current)) return ""
  let computation = ""
  switch (operation) {
    case "+":
      computation = prev + current
      break
    case "-":
      computation = prev - current
      break
    case "*":
      computation = prev * current
      break
    case "÷":
      computation = prev / current
      break
  }

  return computation.toString()
}

const INTEGER_FORMATTER = new Intl.NumberFormat("en-us", {
  maximumFractionDigits: 0,
})
function formatOperand(operand) {
  if (operand == null) return
  const [integer, decimal] = operand.split(".")
  if (decimal == null) return INTEGER_FORMATTER.format(integer)
  return `${INTEGER_FORMATTER.format(integer)}.${decimal}`
}

Dobra, poznajmy jeszcze strukturę projektu. RFC App:

function App() {
  const [{ currentOperand, previousOperand, operation }, dispatch] = useReducer(
    reducer,
    {}
  )

  return (
    <div className="calculator-grid">
      <div className="output">
        <div className="previous-operand">
          {formatOperand(previousOperand)} {operation}
        </div>
        <div className="current-operand">{formatOperand(currentOperand)}</div>
      </div>
      <button
        className="span-two"
        onClick={() => dispatch({ type: ACTIONS.CLEAR })}
      >
        AC
      </button>
      <button onClick={() => dispatch({ type: ACTIONS.DELETE_DIGIT })}>
        DEL
      </button>
      <OperationButton operation="÷" dispatch={dispatch} />
      <DigitButton digit="1" dispatch={dispatch} />
      <DigitButton digit="2" dispatch={dispatch} />
      <DigitButton digit="3" dispatch={dispatch} />
      <OperationButton operation="*" dispatch={dispatch} />
      <DigitButton digit="4" dispatch={dispatch} />
      <DigitButton digit="5" dispatch={dispatch} />
      <DigitButton digit="6" dispatch={dispatch} />
      <OperationButton operation="+" dispatch={dispatch} />
      <DigitButton digit="7" dispatch={dispatch} />
      <DigitButton digit="8" dispatch={dispatch} />
      <DigitButton digit="9" dispatch={dispatch} />
      <OperationButton operation="-" dispatch={dispatch} />
      <DigitButton digit="." dispatch={dispatch} />
      <DigitButton digit="0" dispatch={dispatch} />
      <button
        className="span-two"
        onClick={() => dispatch({ type: ACTIONS.EVALUATE })}
      >
        =
      </button>
    </div>
  )
}

export default App

DigitButton:

import { ACTIONS } from "./App"

export default function DigitButton({ dispatch, digit }) {
  return (
    <button
      onClick={() => dispatch({ type: ACTIONS.ADD_DIGIT, payload: { digit } })}
    >
      {digit}
    </button>
  )
}

I w zasadzie niemal taki sam OperationButton:

import { ACTIONS } from "./App"

export default function OperationButton({ dispatch, operation }) {
  return (
    <button
      onClick={() =>
        dispatch({ type: ACTIONS.CHOOSE_OPERATION, payload: { operation } })
      }
    >
      {operation}
    </button>
  )
}

Nad reducerem mamy zdefiniowane możliwe akcje:

import { useReducer } from "react"
import DigitButton from "./DigitButton"
import OperationButton from "./OperationButton"
import "./styles.css"

export const ACTIONS = {
  ADD_DIGIT: "add-digit",
  CHOOSE_OPERATION: "choose-operation",
  CLEAR: "clear",
  DELETE_DIGIT: "delete-digit",
  EVALUATE: "evaluate",
}

Co nie zmienia faktu, że bez TSa ciężko się ten kod czyta, ale spróbujmy. To znaczy to, co już pokazałem jest mega proste, na tyle proste, że się nie zagłębiam w to.

Zabawa zaczyna się teraz. Mimo wszystko, mimo braku TSa, pomyślmy jaki może być stan:

  • Może być pustym obiektem
  • Może być obiektem, który ma:
    • flagę overwrite o wartości false, false lub null
    • currentOperand o wartości string lub null
    • previousOperand o wartości string lub null
    • operation o wartości string lub null

Tak, nawet w najlepszym kodzie (ten zrobił WebDev) takie rzeczy wychodzą bez TSa. I tych tutaj takich „dziwnych rzeczy”, bardziej takich „baNaNów” (console.log(„ba” + + „hello world”)) niż problemów z logiką jako taką, tutaj trochę się znajdzie.

Ale mniejsza. Rzućmy jeszcze okiem na ten fragment JSXa, byśmy wiedzieli co jest co i jak to się wyświetla:

<div className="calculator-grid">
      <div className="output">
        <div className="previous-operand">
          {formatOperand(previousOperand)} {operation}
        </div>
        <div className="current-operand">{formatOperand(currentOperand)}</div>
      </div>

To formatowanie można sobie darować, ale ok, my tu jesteśmy, aby reducer omówić. No więc tak:


function reducer(state, { type, payload }) {
  switch (type) {
    case ACTIONS.ADD_DIGIT:
      //(...)
    case ACTIONS.CHOOSE_OPERATION:
      //(...)
    case ACTIONS.CLEAR:
      return {}
    case ACTIONS.DELETE_DIGIT:
      //(...)
    case ACTIONS.EVALUATE:
      //(...)
  }
}

Clear już widzimy jak działa. Wraca do initial state, który jest pustym obiektem. Dobra, czyli klik na AC sprawi, że wszystko będzie jak na początku. Fajnie.

Ok, delete digit, jak poprzednie kawałki nie przekonają nas, że TS jest genialny, to może ten to zrobi (TS to takie coś, co najlepiej swoją przydatność udowadnia w projektach, w których go nie ma):

case ACTIONS.DELETE_DIGIT:
      if (state.overwrite) {
        return {
          ...state,
          overwrite: false,
          currentOperand: null,
        }
      }
      if (state.currentOperand == null) return state
      if (state.currentOperand.length === 1) {
        return { ...state, currentOperand: null }
      }

      return {
        ...state,
        currentOperand: state.currentOperand.slice(0, -1),
      }

Więc tak, jeżeli flaga overwrite istnieje i jest prawdziwa… A zaraz, pytanie, czy my wiemy jak kalkulator działa? Bo go piszemy właśnie. Cóż, to jest tak, że jak zrobisz na klawiszu 4 i 5, to masz 45. Ale jak zrobisz 2 + 2 = to dostaniesz 4. I wtedy jak wciśniesz 5, to to 5 zastąpi poprzedni wynik, będzie 5.

I podobnie z usuwaniem. Dasz 4,5 i del, masz 4. Ale dasz 42+ 3 , masz 45. Dasz del, masz puste/zero. O to chodzi z nadpisywaniem.

No i tak, jak nadpisywanie to przy delu zwracamy stan, nadpisywanie na false (może być też null, o tym pamiętajmy) zaś currentOperand na null.

Ok, nie ma nadpisywania? Sprawdzamy, czy current nie jest nullem. Jeśli jest to nie ma co usuwać, zwracamy stan w niezmienionej wersji.

Ok, current ma tylko 1 znak długości? Zwracamy stan, ale current przestawiamy na null.

Pozostał nam przypadek, w którym nie mamy overwrite, current istnieje, ma długość większą niż 1 i wtedy przycinamy go o 1 znak. Ja bym jeszcze sprawdził, czy nie zostawiło nam kropki na końcu. Na zasadzie 1.5 del to ma być 1, nie 1., ale mniejsza.

Ok, spróbujemy zobaczyć teraz evaluate:

case ACTIONS.EVALUATE:
      if (
        state.operation == null ||
        state.currentOperand == null ||
        state.previousOperand == null
      ) {
        return state
      }

      return {
        ...state,
        overwrite: true,
        previousOperand: null,
        operation: null,
        currentOperand: evaluate(state),
      }
  }

Cóż, aby była ewaluacja muszą być trzy rzeczy: poprzedni, operator i obecny. Albo lewo, operator, prawo. Jak nie ma, to zwracamy poprzedni stan.

Jak jest, to włączamy flagę overwrite, poprzedni będzie już nullem, operator też nullem, obecny to będzie wynik. I ten wynik się wyświetli, ale jak będziemy chcieli usunąć coś, to się cały usunie (flaga overwrite), jak będziemy chcieli wklepać cyfrę, to ta cyfra również go zastąpi (flaga overwrite).

Ok, to teraz to dodawanie cyfr zobaczymy:

case ACTIONS.ADD_DIGIT:
      if (state.overwrite) {
        return {
          ...state,
          currentOperand: payload.digit,
          overwrite: false,
        }
      }
      if (payload.digit === "0" && state.currentOperand === "0") {
        return state
      }
      if (payload.digit === "." && state.currentOperand.includes(".")) {
        return state
      }

      return {
        ...state,
        currentOperand: `${state.currentOperand || ""}${payload.digit}`,
      }

Jak overwrite, to nadpisujemy currenta cyfrą i przestawiamy tę flagę na false. Jak 0, to sprawdzamy, czy już nie mamy 0 i blokujemy dodawanie kolejnych zer. Jak kropka – sprawdzamy, czy nie ma już jednej kropki w currentOperand i jeśli tak, blokujemy dodawanie kolejnych.

Tu w przypadku, gdy nie ma flagi overwrite, nie ma próby dodawania 0 do 0, nie ma próby dodawania więcej niż . kropka (swoją drogą z przodu można dodać kropkę, . będze znaczyć 0., parseFloat sobie poradzi) to znaczy, że current jest albo nullem, albo stringiem.

I ten sprytny return sprawia, że niezależnie czy jest nullem czy stringiem będziemy mieli cyfrę wklejoną po prawej stronie. Tym niemniej, nie lubię tego. Brak TSa i cuda z zamianą typów… rozumiem, ale średnio mi pasuje.

Ok, to teraz chooseOperation:

case ACTIONS.CHOOSE_OPERATION:
      if (state.currentOperand == null && state.previousOperand == null) {
        return state
      }

      if (state.currentOperand == null) {
        return {
          ...state,
          operation: payload.operation,
        }
      }

      if (state.previousOperand == null) {
        return {
          ...state,
          operation: payload.operation,
          previousOperand: state.currentOperand,
          currentOperand: null,
        }
      }

      return {
        ...state,
        previousOperand: evaluate(state),
        operation: payload.operation,
        currentOperand: null,
      }

To jest mega. Jeżeli current i prev są nullami, to zwróć stan (nie ma opcji aby dodawać jakieś operatory).

Dobra, to teraz sytuacja, w której current jest nullem, a prev nie (przypadek obu wyłapał if na górze). No wtedy, gdy current jest nullem, a prev nie, to u góry mamy na przyklad 5 +. A chcemy ten plusik zamienić na -. No to wciskamy – i mamy 5-. Kalkulator czeka na currenta, ale zrobiliśmy update operacji, wtedy jeszcze możemy.

Dobra, a jeśli prev jest nullem? A current nie, bo to już wyłapaliśmy. Cóż, to oznacza, że mamy np. 5 i wcisneliśmy +. I teraz trzeba z tego wyświetlacza dużego przesunąć to 5+ na górę.

Krótko mówiąc, teraz mamy curr 5, prev null, akcja wybierz operator, payload +. To robimy curr na null, prev na curr, operation na +.

A pozostały przypadek? To chyba oznacza, że ani nie jest nullem prev, ani curr. I to jest sytuacja, w której mamy 2 + 2 i teraz zamiast dać =, ktoś wciska operator, niech będzie, że znowu +.

To co ma się wykonać? Do curr ma być null, do prev ma trafić 2 + 2, operator to ma być ostatni wciśnięty operator.

Czyli raz jeszcze – wciśnięcie 2 + 2 * ma nam zaowocować tym:

  • curr to null
  • prev to 4
  • operator to *

Ok, to nie był prosty projekt. Sam zrobiłem kalkulator w czystym JS z jeszcze większą ilością opcji, może to później omówimy. Generalnie to jest bardziej podstępny projekt, niż się wydaje (od strony programistycznej).

Więcej JSa, TSa i Reacta niedługo!