Reducers
Las acciones describen que algo pasó, pero no especifican cómo cambió el estado de la aplicación en respuesta. Esto es trabajo de los reducers.
Diseñando la forma del estado
En Redux, todo el estado de la aplicación es almacenado en un único objeto. Es una buena idea pensar en su forma antes de escribir código. ¿Cuál es la mínima representación del estado de la aplicación como un objeto?
Para nuestra aplicación de tareas, vamos a querer guardar dos cosas diferentes:
- El filtro de visibilidad actualmente seleccionado;
- La lista actual de tareas.
Algunas veces verás que necesitas almacenar algunos datos, así como el estado de la UI, en el árbol de estado. Esto está bien, pero trata de mantener los datos separados del estado de la UI.
{
visibilityFilter: 'SHOW_ALL',
todos: [
{
text: 'Consider using Redux',
completed: true,
},
{
text: 'Keep all state in a single tree',
completed: false
}
]
}
Nota sobre relaciones
En aplicaciones más complejas, vas a necesitar tener diferentes entidades que se referencien una a otra. Sugerimos mantener el estado tan normalizado como sea posible, sin nada de anidación. Mantener cada entidad en un objeto con el ID como llave, y usa los IDs para referenciar otras entidades, o para listas. Piensa en el estado de la aplicación como una base de datos. Este enfoque se describe en la documentación de normalizr más detalladamente. Por ejemplo, manteniendo
todosById: { id -> todo }
ytodos: array<id>
dentro del estado es mejor para una aplicación real, pero lo vamos a matener simple para el ejemplo.
Manejando Acciones
Ahora que decidimos cómo se verá nuestro objeto de estado, estamos listos para escribir nuestro reducer. El reducer es una función pura que toma el estado anterior y una acción, y devuelve en nuevo estado.
(previousState, action) => newState
Se llama reducer porque es el tipo de función que pasarías a Array.prototype.reduce(reducer, ?initialValue)
. Es muy importante que los reducer se mantengan puros. Cosas que nunca deberías hacer dentron de un reducer:
- Modificar sus argumentos;
- Realizar tareas con efectos secundarios como llamas a un API o transiciones de rutas.
- Llamar una función no pura, por ejemplo
Date.now()
oMath.random()
.
En la guía avanzada vamos a ver como realizar efectos secundarios. Por ahora, solo recuerda que los reducers deben ser puros. Dados los mismos argumentos, debería calcular y devolver el siguiente estado. Sin sorpresas. Sin efectos secundarios. Sin llamadas a APIs. Sin mutaciones. Solo cálculos.
Con esto dicho, vamos a empezar a escribir nuestro reducer gradualmente enseñandole como entender las acciones que definimos antes.
Vamos a empezar por especificar el estado inicial. Redux va a llamar a nuestros reducers con undefined
como valor del estado la primera vez. Esta es nuestra oportunidad de devolver el estado inicial de nuestra applicación.
import { VisibilityFilters } from './actions'
const initialState = {
visibilityFilter: VisibilityFilters.SHOW_ALL,
todos: []
}
function todoApp(state, action) {
if (typeof state === 'undefined') {
return initialState
}
// Por ahora, no maneja ninguna acción
// y solo devuelve el estado que recibimos.
return state
}
Un estupendo truco es usar la sintáxis de parámetros por defecto de ES6 para hacer lo anterior de forma más compacta:
function todoApp(state = initialState, action) {
// Por ahora, no maneja ninguna acción
// y solo devuelve el estado que recibimos.
return state
}
Ahora vamos a manejar SET_VISIBILITY_FILTER
. Todo lo que necesitamos hacer es cambiar la propiedad visibilityFilter
en el estado. Fácil:
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
})
default:
return state
}
}
Nota que:
No modificamos el
state
. Creamos una copia conObject.assign()
.Object.assign(state, { visibilityFilter: action.filter })
también estaría mal: esto modificaría el primero argumento. Debes mandar un objeto vacío como primer parámetro. También puedes activar la propuesta del operador spread para escribir{ ...state, ...newState }
.Devolvemos el anterior
state
en el casodefault
. Es importarte devolver el anteriorstate
por cualquier acción desconocida.
Nota sobre
Object.assign
Object.assign()
es parte de ES6, pero no esta implementado en la mayoría de los navegadores todavía. Vas a necesitar usar ya sea un polyfill, el plugin de Babel, o alguna otra función como_.assign()
.Nota sobre
switch
y BoilerplateLa sentencia
switch
no es verdadero boilerplate. El verdadero boilerplate de Flux es conceptual: la necesidad de emitir una actualización, la necesidad de registrar el Store con el Dispatcher, la necesidad de que el Store sea un objeto (y las complicaciones que existen para hacer aplicaciones universales). Redux resuelve estos problemas usando reducers puros en vez de emisores de eventos.Desafortunadamente muchos todavía eligen un framework basados en si usan
switch
en su documentación. Si no te gustaswitch
, puedes usar alguna funcióncreateReducer
personalizada que acepte un mapa, como se ve en "Reduciendo el Boilerplate".
Manejando más acciones
¡Todavía tenemos dos acciones más que manejar! Vamos a extender nuestro reducer para manejar ADD_TODO
.
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
})
case ADD_TODO:
return Object.assign({}, state, {
todos: [
...state.todos,
{
text: action.text,
completed: false
}
]
})
default:
return state
}
}
Tal cual como antes, nunca modificamos directamente state
o sus propiedades, en cambio devolvemos un nuevo objeto. El nuevo todos
es igual al viejo todos
agregándole un único objeto nuevo al final. La tarea más nueva es creada usando los datos de la acción.
Finalmente, la implementación de COMPLETE_TODO
no va a venir con ninguna una sorpresa:
case COMPLETE_TODO:
return Object.assign({}, state, {
todos: state.todos.map((todo, index) => {
if (index === action.index) {
return Object.assign({}, todo, {
completed: true
})
}
return todo
})
})
Debido a que queremos actualizar un objeto específico del array sin recurrir a modificaciones, necesitamos crear un nuevo array con los mismo objetos menos el objeto en la posición. Si te encuentras realizando mucho estas operaciones, es una buena idea usar utilidades como react-addons-update, updeep, o incluso una librería como Immutable que tienen soporte nativo a actualizaciones profundas. Solo recuerda nunca asignar nada a algo dentro de state
antes de clonarlo primero.
Separando Reducers
Este es nuestro código hasta ahora. Es algo Here is our code so far. It is rather verboso:
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
})
case ADD_TODO:
return Object.assign({}, state, {
todos: [
...state.todos,
{
text: action.text,
completed: false
}
]
})
case COMPLETE_TODO:
return Object.assign({}, state, {
todos: state.todos.map((todo, index) => {
if(index === action.index) {
return Object.assign({}, todo, {
completed: true
})
}
return todo
})
})
default:
return state
}
}
¿Hay alguna forma de hacerlo más fácil de entender? Parece que todos
y visibilityFilter
se actualizan de forma completamente separadas. Algunas veces campos del estado dependen uno de otro y hay que tener en cuenta más cosas, pero en nuestro caso podemos facilmente actualizar todos
en una función separada:
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [
...state,
{
text: action.text,
completed: false
}
]
case COMPLETE_TODO:
return state.map((todo, index) => {
if (index === action.index) {
return Object.assign({}, todo, {
completed: true
})
}
return todo
})
default:
return state
}
}
function todoApp(state = initialState, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return Object.assign({}, state, {
visibilityFilter: action.filter
})
case ADD_TODO:
case COMPLETE_TODO:
return Object.assign({}, state, {
todos: todos(state.todos, action)
})
default:
return state
}
}
Fijate que todos
acepta state
—¡Pero es un array! Ahora todoApp
solo le manda una parte del estado para que la maneje, y todos
sabe como actualizar esa parte. Esto es llamado composición de reducers, y es un patrón fundamental al construir aplicaciones de Redux.
Vamos a explorar la composición de reducers un poco más. ¿Podemos extraer a otro reducer el control de visibilityFilter
? Podemos:
function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter
default:
return state
}
}
Ahora podemos reescribir el reducer principal como una función que llama a los reducers que controlan distintas partes del estado, y los combina en un solo objeto. Ni siquiera necesita saber el estado inicial. Es suficiente con que los reducers hijos devuelvan su estado inicial cuando reciben undefined
la primera vez.
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [
...state,
{
text: action.text,
completed: false
}
]
case COMPLETE_TODO:
return state.map((todo, index) => {
if (index === action.index) {
return Object.assign({}, todo, {
completed: true
})
}
return todo
})
default:
return state
}
}
function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter
default:
return state
}
}
function todoApp(state = {}, action) {
return {
visibilityFilter: visibilityFilter(state.visibilityFilter, action),
todos: todos(state.todos, action)
}
}
Nota que cada uno de estos reducers esta manejando su propia parte del estado global. El parámetro state
es diferente por cada reducer, y corresponde con la parte del estado que controla.
¡Esto ya se está viendo mejor! Cuando una aplicación es muy grande, podemos dividir nuestros reducers en archivos separados y mantenerlos completamente independientes y controlando datos específicos.
Por último, Redux viene con una utilidad llamada combineReducers()
que realiza la misma lógica que usa todoApp
arriba. Con su ayuda, podemos reescribir todoApp
de esta forma.
import { combineReducers } from 'redux'
const todoApp = combineReducers({
visibilityFilter,
todos
})
export default todoApp
Fíjate que esto es exactamente lo mismo que:
export default function todoApp(state = {}, action) {
return {
visibilityFilter: visibilityFilter(state.visibilityFilter, action),
todos: todos(state.todos, action)
}
}
Además puedes darles diferentes nombres, o llamar funciones diferentes. Estas dos formas de combinar reducers son exactamente lo mismo:
const reducer = combineReducers({
a: doSomethingWithA,
b: processB,
c: c
})
function reducer(state = {}, action) {
return {
a: doSomethingWithA(state.a, action),
b: processB(state.b, action),
c: c(state.c, action)
}
}
Todo lo que combineReducers()
hace es generar una función que llama a tus reducers con la parte del estado seleccionada de acuerdo a su propiedad, y combinar sus resultados en un único objeto. It’s not magic..
Nota para expertos de ES6
Ya que
combineReducers
espera un objeto, podemos poner todos nuestros reducers en un archivo separado,export
cada función reductora, y usarimport * as reducers
para obtenerlos todos juntos como objetos con sus nombres como propiedades.import { combineReducers } from 'redux' import * as reducers from './reducers' const todoApp = combineReducers(reducers)
Ya que
import *
es todavía una sintaxis nueva, no la usamos más en la documentación para evitar confusiones, pero probalemente te encuentro con algunos ejemplos en la comunidad.
Código fuente
reducers.js
import { combineReducers } from 'redux'
import { ADD_TODO, COMPLETE_TODO, SET_VISIBILITY_FILTER, VisibilityFilters } from './actions'
const { SHOW_ALL } = VisibilityFilters
function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter
default:
return state
}
}
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return [
...state,
{
text: action.text,
completed: false
}
]
case COMPLETE_TODO:
return state.map((todo, index) => {
if (index === action.index) {
return Object.assign({}, todo, {
completed: true
})
}
return todo
})
default:
return state
}
}
const todoApp = combineReducers({
visibilityFilter,
todos
})
export default todoApp
Siguientes pasos
A continuación, vamos a ver como crear un store de Redux que contenga todo el estado y se encargue de llamar a nuestro reducer cuando se despache una acción.