Saltearse al contenido

Manejo de estados complejos

En este punto, ya hemos visto cómo manejar estados en componentes funcionales y cómo pasar datos entre componentes a través de las props o del contexto. No obstante, en ocasiones la naturaleza de los datos y componentes de nuestra aplicación puede requerir un manejo de estados más complejo, como por ejemplo, la necesidad de mantener varios estados correlacionados.

Para poder analizar mejor este escenario, vamos a plantear un caso de uso de un hook personalizado que implementamos anteriormente, el cual nos permite gestionar una petición HTTP useFetch.

1
import { useEffect, useState } from "react";
2
3
function useFetch(
4
url,
5
options = {}
6
) {
7
const [data, setData] = useState(null);
8
const [isError, setIsError] = useState(false);
9
const [isLoading, setIsLoading] = useState(true);
10
11
useEffect(() => {
12
setData(null);
13
setIsError(false);
14
setIsLoading(true);
15
16
fetch(url, { ...options })
17
.then((response) => {
18
if (response.ok) {
19
return response.json();
20
}
21
throw Error("Error al relizar la petición");
22
})
23
.then((data) => {
24
setData(data);
25
})
26
.catch((e) => {
27
setIsError(true);
28
})
29
.finally(() => {
30
setIsLoading(false);
31
});
32
}, [url]);
33
34
return [data, isError, isLoading];
35
}
36
37
export default useFetch;

Si recordamos, este hook emplea tres estados para gestionar el estado de una petición HTTP: data, isError e isLoading. Todos ellos deben ser devualtos en un array por el hook para que el componente que lo emplee pueda acceder a ellos.

Por supuesto, podemos notar que existe una clara relación entre estos estados, ya que los componentes que empleen este hook deberán renderizar sus elementos condicionalmente en función de ellos. Por ejemplo, si isLoading es true, se mostrará un mensaje de carga o un spinner, mientras que si isError es true, se deberá informar al usuario de un error en la petición.

Más importante aún, si prestamos atención a la lógica del hook y a la naturaleza de una petición HTTP, podemos notar que este conjunto de estados forman diferentes combiantorias para cada instancia de la petición.

  • Cuando la petición inicia y aún está en curso:
    • data es null.
    • isError es false.
    • isLoading es true.
  • Cuando la petición es exitosa:
    • data contiene los datos de la respuesta, y se representa mediante el tipo Object o Array.
    • isError es false.
    • isLoading es false.
  • Cuando la petición falla:
    • data es null.
    • isError es true.
    • isLoading es false.

Esto implica que, cada instancia de la petición que realicemos con useFetch podría reducir sus estados a una única combinación.

De manera similar, en aplicaciones más complejas, es común que existan situaciones como la anterior, donde varios estados están correlacionados y forman diferentes combinaciones. En estos casos, el manejo de estados simples puede resultar ineficiente y, en el peor de los casos, propenso a errores.

Para abordar este problema, React nos proporciona una solución más avanzada para gestionar estados complejos: los reductores o funciones reducer.

Reductores

Un reductor, es a una función que recibe un estado y una acción, esta última es un objeto que describe un cambio en el estado. La función reducer procesa la acción, actualiza el estado y devuelve un nuevo estado.

Como convención, una función reducer aisla la lógica de actualización del estado mediante la implementación de un switch que evalúa el tipo de acción y actualiza el estado en consecuencia.

1
function reducer(state, action) {
2
switch (action.type) {
3
case <case1>:
4
return { ...state, <newState1>};
5
case <case2>:
6
return { ...state, <newState2>};
7
default:
8
return state;
9
}
10
}

En el ejemplo anterior, podemos resaltar tres detalles importantes:

  1. En la implementación de un reductor, el estado no se actualiza directamente, sino que se devuelve un nuevo estado. Esto es una de las características más importantes de los reductores, ya que garantiza que el estado se actualice de forma inmutable.
  2. Por concención, el parámetro action es un objeto que contiene al menos una propiedad type, la cual describe el tipo de acción que se debe realizar.
  3. Es una buena práctica definir un caso por defecto en el switch que simplemente devuelva el estado actual. Esto es útil para manejar acciones desconocidas o no deseadas.

Retomando la problemática de la petición HTTP y el hook useFetch, notaremos podemos aplicar un reductor para gestionar los estados de la petición de una forma más eficiente.

1
function reducer(state, action) {
2
switch (action.type) {
3
case "FETCH_INIT":
4
return {
5
isError: false,
6
isLoading: true,
7
};
8
case "FETCH_SUCCESS":
9
return {
10
data: action.payload.data,
11
isError: false,
12
isLoading: false,
13
};
14
case "FETCH_FAILURE":
15
return {
16
isError: true,
17
isLoading: false,
18
};
19
default:
20
return state;
21
}
22
}

Como podemos observar, el reductor reducer controla los estados de la petición HTTP a través de tres tipos de acciones: FETCH_INIT, FETCH_SUCCESS y FETCH_FAILURE, los cuales representan las instancias que analizamos previamente.

Tip: En este caso, hemos definido un objeto action.payload que contiene los datos de la respuesta de la petición. Esto es una práctica común en reductores para pasar datos adicionales a través de las acciones.

ACTION TYPES

Si bien no es obligatorio, es una buena práctica definir los tipos de acción como constantes para evitar errores de escritura y facilitar el mantenimiento del código.

Los tipos de acción son constantes que representan el tipo de acción que se debe realizar. Por lo general, se definen como cadenas de texto, aunque también pueden ser números o símbolos.

En el caso de la petición HTTP, los tipos de acción podrían definirse de la siguiente manera:

1
const ACTIONS = {
2
FETCH_INIT: "FETCH_INIT",
3
FETCH_SUCCESS: "FETCH_SUCCESS",
4
FETCH_FAILURE: "FETCH_FAILURE",
5
};

De esta forma, podemos emplear los tipos de acción en el reductor de la siguiente manera:

1
function reducer(state, action) {
2
switch (action.type) {
3
case ACTIONS.FETCH_INIT:
4
return {
5
isError: false,
6
isLoading: true,
7
};
8
case ACTIONS.FETCH_SUCCESS:
9
return {
10
data: action.payload.data,
11
isError: false,
12
isLoading: false,
13
};
14
case ACTIONS.FETCH_FAILURE:
15
return {
16
isError: true,
17
isLoading: false,
18
};
19
default:
20
return state;
21
}
22
}

Nota: ¿Por qué los reducers se llaman así? La palabra reducer proviene de la programación funcional y hace referencia al método reduce que se puede aplicar a los arrays. La operación reduce() permite tomar un array y acumular un único valor a partir de varios:

1
const numbers = [1, 2, 3, 4, 5];
2
const sum = numbers.reduce((accumulator, currentValue) => accumulator + currentValue, 0);

Esta operación requiere de una función para acumular o “reducir” los elementos del array a un único valor. Toma el valor acumulado (accumulator) y el valor actual (currentValue) y devuelve un nuevo valor acumulado. De manera similar, un reductor en React toma un estado y una acción, y devuelve un nuevo estado en función de la acción.

Hook useReducer

Habiendo definido el reductor y los tipos de acción, podemos aplicarlo en un componente funcional (o en un hook personalizado) y desligarnos de los estados individuales que empleábamos anteriormente.

Para ello, React nos proporciona el hook useReducer, el cual nos permite gestionar estados complejos de una forma más eficiente y segura.

Este hook requiere dos argumentos:

  • Un reductor, función que acabamos de analizar.
  • Un estado inicial, que representa el estado inicial del componente.

De forma similar a useState, useReducer devuelve un array con dos elementos:

  • El estado actual.
  • Una función que nos permite “despachar acciones”, es decir, enviar acciones al reductor. Esto permite actualizar el estado y provocar un nuevo renderizado del componente.
1
import { useReducer } from "react";
2
3
//...
4
5
const [state, dispatch] = useReducer(reducer, initialState);

La función dispatch recibe un objeto que representa una acción, el cual debe contener al menos una propiedad type que describa el tipo de acción que se debe realizar. Adicionalmente, puede contener otras propiedades que se pasen al reductor, como payload. Por este motivo, que establecimos cieras convenciones en la definición de una función reducer y de los tipos de acción.

1
dispatch({ type: <actionType>, payload: <payload> });

Una vez más, retomando el ejemplo de la petición HTTP, podemos aplicar useReducer para gestionar los estados de la petición de una forma más eficiente.

1
import { useEffect, useReducer } from "react";
2
3
const ACTIONS = {
4
FETCH_INIT: "FETCH_INIT",
5
FETCH_SUCCESS: "FETCH_SUCCESS",
6
FETCH_FAILURE: "FETCH_FAILURE",
7
};
8
9
function reducer(state, action) {
10
switch (action.type) {
11
case ACTIONS.FETCH_INIT:
12
return {
13
isError: false,
14
isLoading: true,
15
};
16
case ACTIONS.FETCH_SUCCESS:
17
return {
18
data: action.payload.data,
19
isError: false,
20
isLoading: false,
21
};
22
case ACTIONS.FETCH_FAILURE:
23
return {
24
isError: true,
25
isLoading: false,
26
};
27
default:
28
return state;
29
}
30
}
31
32
function useFetch(
33
url,
34
options = {}
35
) {
36
const [state, dispatch] = useReducer(reducer, {
37
data: null,
38
isError: false,
39
isLoading: true,
40
});
41
42
useEffect(() => {
43
dispatch({ type: ACTIONS.FETCH_INIT });
44
45
fetch(url, { ...options })
46
.then((response) => {
47
if (response.ok) {
48
return response.json();
49
}
50
throw Error("Error al relizar la petición");
51
})
52
.then((data) => {
53
dispatch({ type: ACTIONS.FETCH_SUCCESS, payload: { data } });
54
})
55
.catch((e) => {
56
dispatch({ type: ACTIONS.FETCH_FAILURE });
57
});
58
}, [url]);
59
60
return state;
61
}

En React, los reductores se emplean en conjunto con el hook useReducer, el cual nos permite gestionar estados complejos de una forma más eficiente y segura. Aunque existen otras alternativas para gestionar estados complejos, como el uso Redux, useReducer es una opción más ligera y sencilla de implementar.

Conclusiones

Los reductores y el hook useReducer son herramientas poderosas que nos permiten gestionar estados complejos de manera alternativa a useState.

Aunque su uso no es obligatorio, es una buena práctica emplearlos en situaciones donde varios estados están correlacionados, o cuando necesitamos gestionar estados una cantidad significativa de estados.

Por otro lado, debemos recordar que cada acción en un reductor describe una interacción única con un usuario.

Por último, es importante recordar que los reductores y useReducer no reemplazan la utilización de useState y no debe abusarse de ellos.

Bibliografía