Escalando su aplicación Redux con patos

¿Cómo se escala su aplicación front-end? ¿Cómo se asegura de que el código que está escribiendo se pueda mantener dentro de 6 meses?

Redux tomó el mundo del desarrollo de front-end por asalto en 2015 y se estableció como un estándar, incluso más allá del alcance de React.

En la empresa donde trabajo, recientemente terminamos de refactorizar una base de código React bastante grande, agregando redux en lugar de reflujo.

Lo hicimos porque seguir adelante hubiera sido imposible sin una aplicación bien estructurada y un buen conjunto de reglas.

El código base tiene más de dos años y el reflujo estuvo allí desde el principio. Tuvimos que cambiar el código que no se tocó en más de un año y estaba bastante enredado con los componentes de React.

Basándome en el trabajo que hicimos en el proyecto, armé este repositorio, explicando nuestro enfoque para organizar nuestro código redux.

Cuando aprende sobre redux y los roles de las acciones y reductores, comienza con ejemplos muy simples. La mayoría de los tutoriales disponibles en la actualidad no pasan al siguiente nivel. Pero si está creando algo con Redux que es más complicado que una lista de tareas pendientes, necesitará una forma más inteligente de escalar su base de código con el tiempo.

Alguien dijo una vez que nombrar cosas es uno de los trabajos más difíciles de la informática. No podría estar mas de acuerdo. Pero estructurar carpetas y organizar archivos es un segundo cercano.

Exploremos cómo abordamos la organización del código en el pasado.

Función vs característica

Hay dos enfoques establecidos para estructurar aplicaciones: la función primero y la característica primero .

A la izquierda de abajo puede ver una estructura de carpetas de función primero. A la derecha puede ver un enfoque de función primero.

La función primero significa que los directorios de nivel superior reciben el nombre del propósito de los archivos que contienen. Entonces tienes: contenedores , componentes , acciones , reductores , etc.

Esto no escala en absoluto. A medida que su aplicación crece y agrega más funciones, agrega archivos en las mismas carpetas. Por lo tanto, termina teniendo que desplazarse dentro de una sola carpeta para encontrar su archivo.

El problema también consiste en acoplar las carpetas. Un solo flujo a través de su aplicación probablemente requerirá archivos de todas las carpetas.

Una ventaja de este enfoque es que aísla, en nuestro caso, React from redux. Entonces, si desea cambiar la biblioteca de administración de estado, sabe qué carpetas debe tocar. Si cambia la biblioteca de vistas, puede mantener intactas sus carpetas redux.

Feature-first significa que los directorios de nivel superior llevan el nombre de las características principales de la aplicación: producto , carrito , sesión .

Este enfoque escala mucho mejor, porque cada nueva característica viene con una nueva carpeta. Pero no hay separación entre los componentes de React y redux. Cambiar uno de ellos a largo plazo es un trabajo muy complicado.

Además, tiene archivos que no pertenecen a ninguna función. Termina con una carpeta común o compartida, porque desea reutilizar el código en muchas funciones de su aplicación.

Lo mejor de dos mundos

Aunque no está en el alcance de este artículo, quiero tocar esta única idea: siempre separe los archivos de administración de estado de los archivos de interfaz de usuario.

Piense en su aplicación a largo plazo. Imagínese lo que sucede con el código base cuando cambia de React a otra biblioteca. O piense cómo su base de código usaría ReactNative en paralelo con la versión web.

Nuestro enfoque comienza con la necesidad de aislar el código React en una sola carpeta, llamada vistas, y el código redux en una carpeta separada, llamada redux.

Esta división de primer nivel nos da la flexibilidad de organizar las dos partes separadas de la aplicación de forma completamente diferente.

Dentro de la carpeta de vistas, preferimos un enfoque de función primero al estructurar archivos. Esto se siente muy natural en el contexto de React: páginas , diseños , componentes, mejoradores, etc.

Para no volverse loco con la cantidad de archivos en una carpeta, es posible que tengamos una división basada en funciones dentro de cada una de estas carpetas.

Luego, dentro de la carpeta redux ...

Entran re-patos

Cada característica de la aplicación debe mapearse para acciones y reductores separados, por lo que tiene sentido optar por un enfoque de característica primero.

El enfoque modular original de los patos es una buena simplificación para redux y ofrece una forma estructurada de agregar cada nueva característica en su aplicación.

Sin embargo, queríamos explorar un poco qué sucede cuando la aplicación escala. Nos dimos cuenta de que un solo archivo para una función se vuelve demasiado desordenado y difícil de mantener a largo plazo.

Así nació re-ducks . La solución fue dividir cada función en una carpeta de pato .

duck/ ├── actions.js ├── index.js ├── operations.js ├── reducers.js ├── selectors.js ├── tests.js ├── types.js ├── utils.js

Una carpeta de pato DEBE:

  • contener toda la lógica para manejar solo UN concepto en su aplicación, por ejemplo: producto , carrito , sesión , etc.
  • tener un index.jsarchivo que se exporte de acuerdo con las reglas originales del pato.
  • mantener código con un propósito similar en el mismo archivo, como reductores , selectores y acciones
  • contienen las pruebas relacionadas con el pato.

Para este ejemplo, no hemos utilizado ninguna abstracción construida sobre redux. Al crear software, es importante comenzar con la menor cantidad de abstracciones. De esta manera, se asegura de que el costo de sus abstracciones no supere los beneficios.

Si necesita convencerse de que las abstracciones pueden ser malas, mire esta increíble charla de Cheng Lou.

Veamos qué incluye cada archivo.

Tipos

El archivo de tipos contiene los nombres de las acciones que está enviando en su aplicación. Como buena práctica, debe intentar definir el alcance de los nombres según la función a la que pertenecen. Esto ayuda al depurar aplicaciones más complejas.

const QUACK = "app/duck/QUACK"; const SWIM = "app/duck/SWIM"; export default { QUACK, SWIM };

Comportamiento

Este archivo contiene todas las funciones del creador de acciones.

import types from "./types"; const quack = ( ) => ( { type: types.QUACK } ); const swim = ( distance ) => ( { type: types.SWIM, payload: { distance } } ); export default { swim, quack };

Notice how all the actions are represented by functions, even if they are not parametrized. A consistent approach is more than needed in a large codebase.

Operations

To represent chained operations you need a redux middleware to enhance the dispatch function. Some popular examples are: redux-thunk, redux-saga or redux-observable.

In our case, we use redux-thunk. We want to separate the thunks from the action creators, even with the cost of writing extra code. So we define an operation as a wrapper over actions.

If the operation only dispatches a single action — doesn’t actually use redux-thunk — we forward the action creator function. If the operation uses a thunk, it can dispatch many actions and chain them with promises.

import actions from "./actions"; // This is a link to an action defined in actions.js. const simpleQuack = actions.quack; // This is a thunk which dispatches multiple actions from actions.js const complexQuack = ( distance ) => ( dispatch ) => { dispatch( actions.quack( ) ).then( ( ) => { dispatch( actions.swim( distance ) ); dispatch( /* any action */ ); } ); } export default { simpleQuack, complexQuack };

Call them operations, thunks, sagas, epics, it’s your choice. Just find a naming convention and stick with it.

At the end, when we discuss the index, we’ll see that the operations are part of the public interface of the duck. Actions are encapsulated, operations are exposed.

Reducers

If a feature has more facets, you should definitely use multiple reducers to handle different parts of the state shape. Additionally, don’t be afraid to use combineReducers as much as needed. This gives you a lot of flexibility when working with a complex state shape.

import { combineReducers } from "redux"; import types from "./types"; /* State Shape { quacking: bool, distance: number } */ const quackReducer = ( state = false, action ) => { switch( action.type ) { case types.QUACK: return true; /* ... */ default: return state; } } const distanceReducer = ( state = 0, action ) => { switch( action.type ) { case types.SWIM: return state + action.payload.distance; /* ... */ default: return state; } } const reducer = combineReducers( { quacking: quackReducer, distance: distanceReducer } ); export default reducer;

In a large scale application, your state tree will be at least 3 level deep. Reducer functions should be as small as possible and handle only simple data constructs. The combineReducers utility function is all you need to build a flexible and maintainable state shape.

Check out the complete example project and look how combineReducers is used. Once in the reducers.js files and then in the store.js file, where we put together the entire state tree.

Selectors

Together with the operations, the selectors are part of the public interface of a duck. The split between operations and selectors resembles the CQRS pattern.

Selector functions take a slice of the application state and return some data based on that. They never introduce any changes to the application state.

function checkIfDuckIsInRange( duck ) { return duck.distance > 1000; } export default { checkIfDuckIsInRange };

Index

This file specifies what gets exported from the duck folder. It will:

  • export as default the reducer function of the duck.
  • export as named exports the selectors and the operations.
  • export the types if they are needed in other ducks.
import reducer from "./reducers"; export { default as duckSelectors } from "./selectors"; export { default as duckOperations } from "./operations"; export { default as duckTypes } from "./types"; export default reducer;

Tests

A benefit of using Redux and the ducks structure is that you can write your tests next to the code you are testing.

Testing your Redux code is fairly straight-forward:

import expect from "expect.js"; import reducer from "./reducers"; import actions from "./actions"; describe( "duck reducer", function( ) { describe( "quack", function( ) { const quack = actions.quack( ); const initialState = false; const result = reducer( initialState, quack ); it( "should quack", function( ) { expect( result ).to.be( true ) ; } ); } ); } );

Inside this file you can write tests for reducers, operations, selectors, etc.

I could write a whole different article about the benefits of testing your code, there are so many of them. Just do it!

So there it is

The nice part about re-ducks is that you get to use the same pattern for all your redux code.

The feature-based split for the redux code is much more flexible and scalable as your application codebase grows. And the function-based split for views works when you build small components that are shared across the application.

You can check out a full react-redux-example codebase here. Just keep in mind that the repo is still under active development.

How do you structure your redux apps? I’m looking forward to hearing some feedback on this approach I’ve presented.

If you found this article useful, click on the green heart below and I will know my efforts are not in vain.