<porfi.dev/>

Tutorial Contador con useReducer y máquina de estado


Como crear un contador con useReducer en React.js

Vamos a continuar con nuestro contador en react.js. Donde estamos iniciando nuestro control de flujo con máquinas de estado.

En este tutorial usaremos los hooks de useStatey especialmente el useReducer donde controlaremos el estado de nuestro componente con una pseudo máquina de estado.

Demostración

0.00

Código funcionando

A continuación el código funcional del componente, el cual lo puedes importar en cualquier proyecto de react web, si bien que utilice create-react-app o nuestro kit base de react con vite.

import { useEffect, useReducer, useState } from 'react';

// Pseudo Máquina de estado
const cronoMachine = {
  initial: 'idle',
  states: {
    idle: {
      on: {
        TOGGLE: 'running',
      },
    },
    running: {
      on: {
        TOGGLE: 'paused',
      },
    },
    paused: {
      on: {
        TOGGLE: 'running',
        RESET: 'idle',
      },
    },
  },
};

// Reducer
const cronoReducer = (state, event) => {
  return cronoMachine.states[state].on[event.type] || [state];
};

// Componente
const Crono = () => {
  const [time, setTime] = useState({
    counter: 0,
    startTimeDate: null,
    pausedTimeDate: null,
  });

  const [state, dispatch] = useReducer(cronoReducer, cronoMachine.initial);

  useEffect(() => {
    if (state === 'running') {
      if(time.startTimeDate === null) {
        setTime((time) => ({
          ...time,
          startTimeDate: new Date(),
        }));

        const interval = setInterval(() => {
          setTime((time) => ({
            ...time,
            counter: (new Date() - time.startTimeDate) / 1000
          }));
        }, 100);

        return () => clearInterval(interval);
      }

      setTime((time) => ({
        ...time,
        startTimeDate: new Date()
      }))

      const interval = setInterval(() => {
        setTime((time) => ({
          ...time,
          counter: time.pausedTimeDate + (new Date() - time.startTimeDate) / 1000
        }));
      }, 100);

      return () => clearInterval(interval);
    }

    if (state === 'paused') {
      setTime((time) => ({
        ...time,
        counter: time.counter,
        startTimeDate: new Date(),
        pausedTimeDate: time.counter,
      }));
    }

    if (state === 'idle') {
      setTime((time) => ({
        ...time,
        counter: 0,
        startTimeDate: null,
        pausedTimeDate: null,
      }));
    }
  }, [state]);

// handlers
  const handlePress = () => {
    dispatch({ type: 'TOGGLE' });
  };

  const handleReset = () => {
    dispatch({ type: 'RESET' });
  };

  const renderActionButton = (state) => {
    switch (state) {
      case 'idle':
        return <button onClick={handlePress}>Iniciar Contador</button>;
      case 'paused':
        return (
          <>
            <button onClick={handlePress}>Iniciar Contador</button>
            <button onClick={handleReset}>Reiniciar Contador</button>
          </>
        );
      case 'running':
        return <button onClick={handlePress}>Pausar Contador</button>;
      default:
        return <p>Aquí hay un error</p>;
    }
  };

  return (
    <div>
      <span>{time.counter.toFixed(2)}</span>
      <br />
      {renderActionButton(state)}
    </div>
  );
};

export { Crono };

Explicación del código

Generamos una pseudo máquina de estado, donde cada estado tiene un evento que nos lleva a otro estado, de esta forma la máquina controla los estados y eventos de la aplicación que se pueden ejecutar determinado momento.

const cronoMachine = {
  initial: 'idle',
  states: {
    idle: {
      on: {
        TOGGLE: 'running',
      },
    },
    running: {
      on: {
        TOGGLE: 'paused',
      },
    },
    paused: {
      on: {
        TOGGLE: 'running',
        RESET: 'idle',
      },
    },
  },
};

Utilizando el useReducer

A diferencia de manejar el hook de useState el uso de useReducer nos exige una serie de tipos de evento, así como el valor del estado almacenado.

Considerar que el estado state es, en si el objeto con los valores que vamos a manipular y que controlaran la vista de nuestro componente react.js

const cronoReducer = (state, event) => {
  return cronoMachine.states[state].on[event.type] || [state];
};

//...
const [state, dispatch] = useReducer(cronoReducer, cronoMachine.initial);

Nuestro botones, a diferencia del caso que vimos en Tutorial contador Automatico con React.js aquí solo hacen un lanzamiento de eventos al reducer, como se ve en el siguiente código:

const handlePress = () => {
    dispatch({ type: 'TOGGLE' });
  };

const handleReset = () => {
  dispatch({ type: 'RESET' });
};

En el caso de nuestro hook de useEffect estará esperando los cambios en el estado state que controla el useReducer y nuestra función reductora.

Dependiendo del valor del state evalua las condiciones y continua con la ejecución del contador.

useEffect(() => {
    if (state === 'running') {
      // .. Al iniciar el contador
      }

      // .. Al reanudar el contador despues de una pausa
    }

    if (state === 'paused') {
      // .. Al pausar el contador
    }

    if (state === 'idle') {
      // .. Al cargar el component o hacer un
      // reset al contador
    }
  }, [state]);

El state también aplica a los botones que se visualizan en el componente, con nuestra función de renderActionButton

const renderActionButton = (state) => {
    switch (state) {
      case 'idle':
         // ...
      case 'paused':
         // ...
      case 'running':
         // ...
      default:
        return <p>Aquí hay un error</p>;
    }
  };

Estamos a un pasito más de empezar a utilizar una librería especializada en las maquinas de estado, aunque en este ejemplo llegamos prácticamente a la misma funcionalidad con ayuda de useReducer

A continuación el video con el mismo código, funcionando, donde hago algunos comentarios extras y otras acotaciones que quizá por mi mala memoria olvide escribir aquí.

https://youtu.be/XBSzVqBDvp8