useContext y useReducer. ¿Cómo replicar redux?

useContext y useReducer. ¿Cómo replicar redux?

Estos dos React hooks se complementan a la hora de crear aplicaciones. Si tu aplicación no es tan grande y compleja, estos dos react hooks te ayudaran a manejar el estado global de una forma muy similar a como lo hace Redux.

Si no estás familiarizado con Redux, te recomiendo revisar los principios usados en Redux. Además la combinación de useContext y useReducer es un buen punto de partida para empezar a entender como funciona.

Primero aprenderemos a utilizar useContext, luego useReducer, y finalmente uniremos estos dos React Hooks para replicar el comportamiento básico de Redux y controlar el estado global de una pequeña aplicación.

Contexto

La idea del contexto es que ahí guardes cierta información que necesitas en varios componentes. El problema a resolver es que esos componentes se encuentran en diferentes niveles del árbol de nodos de tu aplicación. Por esta razón, una instancia de un contexto contiene dos propiedades componentes, un Proveedor y un Consumidor (Provider, Consumer). Como consecuencia el proveedor almacena y suministra los datos, mientras el consumidor pide acceso a ellos.

UnContexto = React.createContext({});
// UnContexto = { Provider, Consumer }
....
<UnContexto.Provider>
<UnContexto.Consumer>

Agregando a useContext, existen tres formas de consumir el contexto actual de una aplicación

  1. static contextType = UnContexto
  2. <UnContexto.Consumer>
  3. useContext(UnContexto)

Las tres maneras de obtener el contexto depende de que exista su correspondiente componente proveedor padre <UnContexto.Provider>. De las tres, la forma **más simple es useContext(UnContexto), punto a favor de los React Hooks.

A continuación vamos a describir el ejemplo para la creación del contexto, más tres ejemplos de los modos de consumirlo.

createContext(defaultContext)

Primero necesitamos crear un contexto para poder consumirlo, la forma de crear el contexto no cambia para los consumidores.

Creamos un nuevo proyecto en codesandbox.io y creamos un archivo llamado UserNameContext.js con el siguiente contenido. En el ejemplo queremos obtener del nombre de un usuario en nuestra aplicación, que se usa en distintos componentes.

import { createContext } from 'react';

const UserNameContext = createContext('');

export default UserNameContext

Ahora en nuestro archivo App.js importamos el contexto.

import UserNameContext from './UserNameContext';

Necesitamos suministrar a la aplicación con los datos de nuestro contexto, para eso ocupamos al componente <UserNameContext.Provider> y actualizamos el valor del contexto cuando el usuario escribe en el campo de texto.

import { useState } from 'react';
import Header from './components/Header';
import UserNameContext from './UserNameContext';

function App() {
  const [userName, setUserName] = useState('');

  return (
    <UserNameContext.Provider value={userName}>
      <h1>static contextType</h1>
      <Header />
      <input type="text" value={userName} onChange={e => setUserName(e.target.value)}/>
    </UserNameContext.Provider>
  );
}

export default App;

Para que quede claro la utilidad del contexto, creamos dos componentes, <Header> y <UserInfo> dentro de la carpeta llamada components.

import UserInfo from './UserInfo';

export default function Header() {

  return (
    <header>
      <h2>Header</h2>
      <UserInfo />
    </header>
  );
}

El componente <Header> es padre del componente <UserInfo>, de esta manera se ejemplifica el consumo del contexto en un tercer nivel de componentes.

El componente de <UserInfo /> contendrá al consumidor del contexto. Así que este es el único componente que cambiara su implementación para ejemplificar los tres modos de consumo del contexto. En las siguientes tres secciones se muestra cada forma.

static contextType = UnContexto

Para usar esta forma de consumir el contexto, debemos crear un componente de clase. La importación del contexto debe aplicarse también para los otros dos formas. Es algo que no cambia en las tres implementaciones.

import { Component } from 'react';
import UserNameContext from '../UserNameContext';

export default class UserInfo extends Component {
  static contextType = UserNameContext;

  render() {
    const value = this.context;

    return (
      <p>{value}</p>
    );
  }
}

Simplemente se agrega una propiedad estática contextType a la clase, asignándole como valor el objeto contexto generado por createContext(initialContext) anteriormente.

Luego podemos usar this.context en cualquiera de los métodos del ciclo de vida de un componente en React, para hacer referencia al valor actual del contexto y utilizarlo. Tal cual como se muestra en el ejemplo de arriba, donde utilizamos el método render().

<UnContexto.Consumer>

Esta manera de consumir el valor del contexto, pude usarse en un componente de clase o en un componente funcional, necesitamos importar el objeto del contexto y utilizar el componente Consumer que está definido como propiedad del objeto contexto.

Al componente Consumer mencionado en el párrafo anterior le pasamos como children una función, la cual puede recibir como primer parámetro el valor actual del contexto. Por último en este ejemplo mostramos el valor del contexto en una etiqueta <p>.

import UserNameContext from '../UserNameContext';

export default function UserInfo() {
  return (
    <UserNameContext.Consumer>
      {value => (<p>{value}</p>)}
    </UserNameContext.Consumer>
  );
}

useContext(UnContexto)

Uno de los objetivos de esta publicación es mostrar como usar el React Hook useContext(UnContexto), este hook es el equivalente a la propiedad static contextTypes = UnContexto en un componente de clase o el uso del componente <UnContexto.Consumer>, es decir, también puede obtener el valor actual del contexto y subscribirse a cambios del mismo.

import { useContext } from 'react';
import UserNameContext from '../UserNameContext';

export default function UserInfo() {
  const value  = useContext(UserNameContext);
  
  return (
    <p>{value}</p>
  );
}

Igual que las demás formas, importamos el objeto contexto, luego dentro del componente funcional, invocamos a useContext pasándole como parámetro el propio contexto. Así obtenemos el valor actual del contexto y lo guardamos en la constante value. Si el contexto cambia, esto provocara que el componente <UserInfo /> se vuelva a renderizar con el nuevo valor del contexto.

Compara la complejidad de obtener el contexto con useContext(UserNameContext), static contextType = UserNameContext y <UserNameContext.Consumer>.

Aquí puedes ver el ejemplo completo:

Edit useContext(UserNameContext)

useReducer

Este React Hook es bastante interesante como alternativa a useState, se recomienda usarlo cuando:

  • Necesitas controlar el estado en algún objeto complejo, lo más común es en un objeto literal o Arreglo
  • Realizar lógica compleja relacionada con el estado previo para calcular el próximo estado.

useReducer, como su nombre lo indica utiliza una función reducer, esta función es la que se encarga de manejar la complejidad de los cambios en el estado de la misma forma que los reducers en Redux.

Sintaxis

La sintaxis es la siguiente

const [state, dispatch] = useReducer(reducerFn, initialState, initLazyFn)
  1. reducerFn, es la función reducer encargada de la lógica de actualizar el estado, es una función en la forma (state, action) => { }.
  2. initialState, es el estado inicial.
  3. initLazyFn, este parámetro es opcional, se usa para calcular el estado inicial final basado en el initialState, se invoca initLazyFn(initialState) para obtener el estado final. Útil para separar la lógica del cálculo del estado inicial de la función reducerFn.

Ejemplo

Creamos un nuevo proyecto en codesandbox.io, y en el archivo App.js ponemos lo siguiente:

import { useReducer } from "react";
import INITIAL_STATE from "./initialState";
import parseInitialState from "./parseInitialState";
import { reduceUser } from "./reducer";
import "./styles.css";

export default function App() {
  const [state, dispatch] = useReducer(
    reduceUser,
    INITIAL_STATE,
    parseInitialState
  );

  function saveUser(e) {
    e.preventDefault();
    saveUserToLocalStorage(state);
  }
  
  return (
    <div className="App">
      <h1>useReducer(reducerFn, initState, parseInitialState)</h1>
        <form>
       ...
      </form>
    </div>
  );
}

El ejemplo es un formulario donde se guarda el nombre, apellido y edad de un usuario conforme escribe en sus respectivos inputs. Luego hasta el final tiene un botón para guardar al usuario en locaStorage al enviar el formulario.

Se tiene en archivos diferentes a initialState, initLazyFn (parseInitialState) y reduceFn(reduceUser).

initialState e initiLazyFn

initialState.js, como ya mencionamos es el estado inicial.

 export default {
  name: "",
  age: 0,
  lastName: ""
};

parseInitialState.js (initLazyFn), dado que al enviar el formulario se guarda en el localStorage, se debe parsear los datos del localStorage si es que existen.

export default function (initialState) {
  const user = localStorage.getItem("user");

  if (user) {
    return parseJson(user);
  }

  return initialState;
}

function parseJson(json) {
  try {
    return JSON.parse(json);
  } catch (error) {
    console.log(error);
  }
}

Función reducer

reduceUser.js, la mayoría de las acciones realmente no son tan complejas, pero tampoco tan simples para usarlo en un useState(). El usuario guardado se puede almacenar en un arreglo de usuarios y que sea parte del estado, sin embargo no queremos complicar más las cosas, además necesitaríamos crear un componente que liste los usuarios.

export function reduceUser(state, action) {
  switch (action.type) {
    case "updateName":
      return { ...state, name: action.payload };
    case "updateLastName":
      return { ...state, lastName: action.payload };
    case "updateAge":
      return { ...state, age: action.payload };
    default:
      return state;
  }
}

Ahora bien, ya que tenemos todo el código de los archivos separados, podemos revisar el archivo App.js. En el componente App, en lugar de usar useState, se invoca a useReducer.

  const [state, dispatch] = useReducer(
    reduceUser,
    INITIAL_STATE,
    parseInitialState
  );

La invocación nos regresa un par de valores, el estado generado y una función dispatch, para mandar a ejecutar las acciones.

Por último nos faltan los campos para los datos del usuario y como se disparan las acciones.

import { useReducer } from "react";
import INITIAL_STATE from "./initialState";
import parseInitialState from "./parseInitialState";
import { reduceUser } from "./reducer";
import "./styles.css";

export default function App() {
  const [state, dispatch] = useReducer(
    reduceUser,
    INITIAL_STATE,
    parseInitialState
  );

  function saveUser(e) {
    e.preventDefault();
    saveUserToLocalStorage(state);
  }

  return (
    <div className="App">
      <h1>useReducer(reducerFn, initState, parseInitialState)</h1>

      <form onSubmit={saveUser}>
        <label>
          Nombre:
          <input
            type="text"
            value={state.name}
            onChange={(e) =>
              dispatch({ type: "updateName", payload: e.target.value })
            }
          />
        </label>
        <label>
          Apellidos:
          <input
            type="text"
            value={state.lastName}
            onChange={(e) =>
              dispatch({ type: "updateLastName", payload: e.target.value })
            }
          />
        </label>
        <label>
          Edad:
          <input
            type="number"
            value={state.age}
            onChange={(e) =>
              dispatch({ type: "updateAge", payload: e.target.value })
            }
          />
        </label>

        <button>Guardar en localStorage</button>
      </form>

      {JSON.stringify(state)}
    </div>
  );
}

function saveUserToLocalStorage(state) {
  try {
    localStorage.setItem("user", JSON.stringify(state));
  } catch (error) {
    console.log(error);
  }
}

Se utiliza el evento onChange para disparar acciones con dispatch() y el evento onSubmit en el formulario para guardar en localStorage ("saveUser"). Aquí abajo el ejemplo completo.

Edit useReducer(reducer, initState, initLazyFn)

Preparando codigo para combinar useContext y useReducer

Vamos a mejorar el ejemplo anterior, siguiendo las pautas que nos indica Redux, agregaremos todo lo relacionado con el estado en una carpeta llamada store.

Primero definimos el nombre de funciones como constantes en el archivo /store/actionTypes.js, para hacer referencia a ellas sin algún error de escribirlo incorrectamente.

actionTypes.js

export const UPDATE_NAME = "updateName";
export const UPDATE_LASTNAME = "upateLastName";
export const UPDATE_AGE = "updateAge";

actions.js

Segundo, definimos nuestras acciones como funciones que regresan el objeto necesario para la funcion reduceUser. En /store/actions.js

import { UPDATE_NAME, UPDATE_LASTNAME, UPDATE_AGE } from "./actionTypes";

export const updateName = (name) => ({ type: UPDATE_NAME, payload: name });

export const updateLastName = (lastName) => ({
  type: UPDATE_LASTNAME,
  payload: lastName
});

export const updateAge = (age) => ({ type: UPDATE_AGE, payload: age });

Función reducerUser

Tercero, actualizamos la función reduceUser con los nuevos identificadores para las acciones. En el archivo /store/reduceUser.js

import { UPDATE_NAME, UPDATE_LASTNAME, UPDATE_AGE } from "./actionTypes";

export function reduceUser(state, action) {
  const { type, payload } = action;

  switch (type) {
    case UPDATE_NAME:
      return { ...state, name: payload };
    case UPDATE_LASTNAME:
      return { ...state, lastName: payload };
    case UPDATE_AGE:
      return { ...state, age: payload };
    default:
      return state;
  }
}

Uniendo todo en App.js

Y finalmente vamos a actualizar las importaciones y las invocamos las funciones actions en App.js. Esto último para obtener el objeto action que necesita reduceUser(state, action) y tener un código más corto y entendible. Al menos en este caso nos evitamos la notación dispatch({ type: 'updateLastName', payload: e.target.value }). Ahora solo hacemos dispatch(updateLastName(e.target.value))

import { useReducer } from "react";
import INITIAL_STATE from "./store/initialState";
import parseInitialState from "./store/parseInitialState";
import { reduceUser } from "./store/reduceUser";
import { updateName, updateLastName, updateAge } from "./store/actions";
import "./styles.css";

export default function App() {
  const [state, dispatch] = useReducer(
    reduceUser,
    INITIAL_STATE,
    parseInitialState
  );

  function saveUser(e) {
    e.preventDefault();
    saveUserToLocalStorage(state);
  }

  return (
    <div className="App">
      <h1>useReducer(reducerFn, initState, initLazyFn)</h1>

      <form onSubmit={saveUser}>
        <label>
          Nombre:
          <input
            type="text"
            value={state.name}
            onChange={(e) => dispatch(updateName(e.target.value))}
          />
        </label>
        <label>
          Apellidos:
          <input
            type="text"
            value={state.lastName}
            onChange={(e) => dispatch(updateLastName(e.target.value))}
          />
        </label>
        <label>
          Edad:
          <input
            type="number"
            value={state.age}
            onChange={(e) => dispatch(updateAge(e.target.value))}
          />
        </label>

        <button>Guardar en localStorage</button>
      </form>

      {JSON.stringify(state)}
    </div>
  );
}

function saveUserToLocalStorage(state) {
  try {
    localStorage.setItem("user", JSON.stringify(state));
  } catch (error) {
    console.log(error);
  }
}

Mini redux con useContext y useReducer

En esta sección vamos a utilizar useContext y useReducer para crear una pequeña aplicación al estilo Redux.

Primero vamos a dividir nuestra pequeña aplicación en varios componentes, agregaremos un componente

el cual contendrá a otro llamado . Luego otro componente donde usaremos el contexto para usar las acciones de nuestro reducer y el estado.

Header y UserInfo

Creamos una carpeta llamada components y creamos tres archivos, Header.js, UserInfo.js y UserForm.js.

Archivo /components/Header.js.

import UserInfo from './UserInfo';

export default function Header() {

  return (
    <header>
      <h2>Header</h2>
      <UserInfo />
    </header>
  );
}

Archivo /components/UserInfo.js

import { useContext } from 'react';
import UserContext from '../store/UserContext';

export default function UserInfo() {
  const [state]  = useContext(UserContext);
  
  return (
    <>
      <p>Nombre: {state.name}</p>
      <p>Apellidos: {state.lastName}</p>
      <p>Edad: {state.age}</p>
    </>
  );
}

En el componente <UserInfo>, a diferencia de la primera versión que hicimos al inicio de esta publicación, el valor del contexto es un arreglo conteniendo state y dispatch. Solo que en solo nos interesa state.

Antes de crear el componente , vamos a crear el contexto que se utilizara en y en .

Crear contexto

Creamos el archivo /store/UserContext.js dentro de la carpeta store

import { createContext } from 'react';

const UserContext = createContext(\[\]);

export default UserContext;

Como vemos el contexto contiene un arreglo. De momento vacío.

Uso de useContext y useReducer para crear el store

Luego en App.js utilizamos este contexto para que todos los componentes hijos puedan tener acceso.

import { useReducer } from "react";
import INITIAL_STATE from "./store/initialState";
import parseInitialState from "./store/parseInitialState";
import { reduceUser } from "./store/reduceUser";
import UserContext from './store/UserContext';
import "./styles.css";

export default function App() {
  const [state, dispatch] = useReducer(
    reduceUser,
    INITIAL_STATE,
    parseInitialState
  );
  
  return (
      <UserContext.Provider value={[state, dispatch]}>
      <div className="App">
        <h1>useReducer(reducerFn, initState, initLazyFn)</h1>
        <Header />
        ....
      </div>
    </UserContext.Provider>
  );
}

Como vemos agregamos el componente <Provider> del objeto UserContext y le asignamos como valor un arreglo conteniendo state y el dispatch. Si queremos podemos pasar solo una variable, así podemos decir que el valor del contexto es el store, en realidad esto es igual a como lo hace Redux, solo que redux trae su propio Provider.

export default function App() {
  const store = useReducer(
    reduceUser,
    INITIAL_STATE,
    parseInitialState
  );
  
  return (
      <UserContext.Provider value={store}>
      <div className="App">
        <h1>useReducer(reducerFn, initState, initLazyFn)</h1>
        <Header />
        ....
      </div>
    </UserContext.Provider>
  );
}

Usando useContext para usar store en UserForm

Ahora si podemos crear el componente que utilizara el contexto y disparará las acciones a nuestra función reduceUser, es decir, utilizara el store de la aplicación.

Archivo /components/UserForm.js. Simplemente copiamos el contenido JSX del formulario que estaba App.js, importamos los action creators y el UserContext para utilizar useContext(UserContext) y obtener el store.

import { useContext } from "react";
import { updateName, updateLastName, updateAge } from "../store/actions";
import UserContext from "../store/UserContext";

export default function UserForm() {
  const [state, dispatch] = useContext(UserContext);

  function saveUser(e) {
    e.preventDefault();
    saveUserToLocalStorage(state);
  }

  return (
    <form onSubmit={saveUser}>
      <label>
        Nombre:
        <input
          type="text"
          value={state.name}
          onChange={(e) => dispatch(updateName(e.target.value))}
        />
      </label>

      <label>
        Apellidos:
        <input
          type="text"
          value={state.lastName}
          onChange={(e) => dispatch(updateLastName(e.target.value))}
        />
      </label>
      
      <label>
        Edad:
        <input
          type="number"
          value={state.age}
          onChange={(e) => dispatch(updateAge(e.target.value))}
        />
      </label>

      <button>Guardar en localStorage</button>
    </form>
  );
}

function saveUserToLocalStorage(state) {
  try {
    localStorage.setItem("user", JSON.stringify(state));
  } catch (error) {
    console.log(error);
  }
}

Agregar

Finalmente podemos usar la etiqueta en nuestro archivo App.js

import { useReducer } from "react";
import INITIAL_STATE from "./store/initialState";
import parseInitialState from "./store/parseInitialState";
import { reduceUser } from "./store/reduceUser";
import UserContext from './store/UserContext';
import Header from './components/Header';
import UserForm from './components/UserForm';
import "./styles.css";

export default function App() {
  const store = useReducer(
    reduceUser,
    INITIAL_STATE,
    parseInitialState
  );

  return (
    <UserContext.Provider value={storre}>
      <div className="App">
        <h1>useReducer(reducerFn, initState, initLazyFn)</h1>
        <Header />
        <UserForm />
      </div>
    </UserContext.Provider>
  );
}

Listo podemos tener nuestra aplicación con un store igual al de Redux sin importar esta librería, esta funcionalidad ya viene construida dentro de React.

Aquí puedes ver el ejemplo completo.

Edit useContext y useReducer, replicando Redux

Conclusiones

Por si no sabias como usar el contexto, en esta publicación aprendimos como crearlo y suministrar su valor a cualquier elemento hijo del árbol de nodos. Después para consumir el valor utilizamos las tres formas de hacerlo:

  1. static contextType = UnContexto
  2. <UnContexto.Consumer>
  3. useContext(UnContexto)

Al final utilizamos el React Hook useContext y comparamos las complejidades, por consiguiente se puede notar que el useContext es la forma más simple de obtener el valor del contexto.

Aprendimos a utilizar useReducer() y como su funcionalidad es casi idéntica a los reducers de Redux. Luego mejoramos el código para separar la función reducer, los actions y los action types tal cual se haría en Redux separando responsabilidades.

Finalmente usamos createContext, useContext y useReducer para tener acceso al store de la misma forma que lo hacemos con Redux. Concluyendo que sin la necesidad de una librería de terceros, como lo es Redux, React ya contiene este tipo de funcionalidad gracias a los React Hooks useContext y useReducer. Estos hooks son ideales para aplicaciones pequeñas que no necesiten de las funcionalidades avanzadas de Redux.

Referencias

https://reactjs.org/docs/context.htm

https://reactjs.org/docs/hooks-reference.html#usecontext

https://reactjs.org/docs/hooks-reference.html#usereducer

Podría interesarte