Redux: Módulos e “code-splitting”

Modularizando sua estrutura Redux como o Twitter Lite

Image for post
Image for post
Em aplicações grandes fazer “code-splitting” é necessários, e o Redux, como fica?

Twitter Lite usa Redux para gerenciar seu estado e depende de code-splitting. Porém, a API padrão do Redux não é feita para aplicações que carregam funcionalidades incrementalmente durante a sessão do usuário — Nicolas Gallagher

Nesse artigo, iremos falar sobre como adicionar suporte para carregamento incremental e módulos na estrutura Redux do Twitter Lite. É uma forma bem direta e comprovada em um produto em produção por vários anos.

Redux em módulos

Módulos em Redux compõe reducer, actions, action creators e selectors. Organizar seu código redux em módulos independentes torna possível criar uma API que não envolve referências diretas para o estado interno de um reducer — deixando refatoração e testes bem mais simples (veja um exemplo completo esse repositório).

Vamos ver um pequeno exemplo de “redux em módulo”:

// data/notifications/index.jsconst initialState = [];
let notificationId = 0;
const createActionName = name => `app/notifications/${name}`;// reducer
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
case ADD_NOTIFICATION:
return [...state, { ...action.payload, id: notificationId += 1 }];
case REMOVE_NOTIFICATION:
return state.slice(1);
default:
return state;
}
}
// selectors
export const selectAllNotifications = state => state.notifications;
export const selectNextNotification = state => state.notifications[0];
// actions
export const ADD_NOTIFICATION = createActionName(ADD_NOTIFICATION);
export const REMOVE_NOTIFICATION = createActionName(REMOVE_NOTIFICATION);
// action creators
export const addNotification = payload => ({ payload, type: ADD_NOTIFICATION });
export const removeNotification = () => ({ type: REMOVE_NOTIFICATION });

Esse módulo pode ser usado para adicionar e selecionar notificações. Abaixo, temos um exemplo de como ele seria utilizado para disponibilizar props a um componente React:

// components/NotificationView/connect.jsimport { connect } from 'react-redux';
import { createStructuredSelector } from 'reselect';
import { removeNotification, selectNextNotification } from '../../data/notifications';
const mapStateToProps = createStructuredSelector({
nextNotification: selectNextNotification
});
const mapDispatchToProps = { removeNotification };
export default connect(mapStateToProps, mapDispatchToProps);

E então:

// components/NotificationView/index.jsimport connect from './connect';
export class NotificationView extends React.Component { /*...*/ }
export default connect(NotificationView);

Isso permite que você importe módulos específicos que são responsáveis por modificar e selecionar partes do estado da sua aplicação. Isso pode ser bem útil quando utilizado com code-splitting.

Porém, um problema com essa abordagem fica evidente quando chegamos na parte de adicionar um reducer a nossa store:

// data/createStore.jsimport { combineReducers, createStore } from 'redux';
Import notifications from './notifications';
const initialState = /* localStorage ou servidor */const reducer = combineReducers({ notifications });
const store = createStore(reducer, initialState);
export default store;

Você irá notar que o nome notifications é definido no momento da criação da store, e não quando usamos o nosso módulo Redux que define o reducer. Se nomearmos notifications com outro nome em createStore, todos os nossos selectors no nosso módulo Redux não irão mais funcionar. Pior, cada módulo Redux precisa ser carregado nesse createStore antes de criarmos a store. Isso não escala muito bem para grandes e complexas aplicações que dependem de code-splitting para carregar módulos incrementalmente. Uma grande aplicação pode ter dezenas de módulos Redux, e boa parte deles são utilizados em apenas alguns componentes e são desnecessários para a renderização inicial.

Essas duas situações podem ser evitadas se introduzirmos uma etapa de registro de reducers no Redux.

Registro de reducers no Redux

Essa etapa de registro, permite que reducers sejam adicionados a store após a ela ter sido criada. Isso permite que módulos Redux possam ser carregados sob demanda, sem precisarmos importar todos os módulos Redux na inicialização da store:

// data/reducerRegistry.jsexport class ReducerRegistry {
constructor() {
this._emitChange = null;
this._reducers = {};
}
getReducers() {
return { ...this._reducers };
}
register(name, reducer) {
this._reducers = { ...this._reducers, [name]: reducer };
if (this._emitChange) {
this._emitChange(this.getReducers());
}
}
setChangeListener(listener) {
this._emitChange = listener;
}
}
const reducerRegistry = new ReducerRegistry();
export default reducerRegistry;

Agora, cada módulo Redux pode ser registrado independentemente e definir seu próprio nome de reducer:

// data/notifications/index.js// [A] importando nosso módulo de registro
import reducerRegistry from '../reducerRegistry';
const initialState = [];
let notificationId = 0;
// [B] nome único desse módulo
const reducerName = 'notifications';
// [C] utilizamos o escopo em `${reducerName}`
const createActionName = name => `app/${reducerName}/${name}`;
// reducer
export default function reducer(state = initialState, action = {}) {
switch (action.type) {
case ADD_NOTIFICATION:
return [...state, { ...action.payload, id: notificationId += 1 }];
case REMOVE_NOTIFICATION:
return state.slice(1);
default:
return state;
}
}
// [D] registramos nosso reducer
reducerRegistry.register(reducerName, reducer);
// [E] seletores dependem do nome do reducer em `[reducerName]`
// selectors
export const selectAllNotifications = state => state[reducerName];
export const selectNextNotification = state => state[reducerName][0];
// actions
export const ADD_NOTIFICATION = createActionName(ADD_NOTIFICATION);
export const REMOVE_NOTIFICATION = createActionName(REMOVE_NOTIFICATION);
// action creators
export const addNotification = payload => ({ payload, type: ADD_NOTIFICATION });
export const removeNotification = () => ({ type: REMOVE_NOTIFICATION });

Em seguida, precisamos mudar o método de combinação de reducers na inicialização da nossa store, precisamos escutar e incluir quando um novo reducer for registrado (por exemplo, carregado sob demanda). Isso é um pouco complicado pela necessidade de preservar o estado inicial que pode ter sido criado por reducers que ainda não foram carregados no cliente. Por padrão, quando uma ação é despachada, Redux irá ignorar e jogar fora o estado que não corresponde a um atual reducer. Para evitar isso, precisamos preservar esse estado, retornando o próprio quando o reducer não for encontrado:

// data/createStore.jsimport { combineReducers, createStore } from 'redux';
import reducerRegistry from './reducerRegistry';
const initialState = /* localStorage ou servidor */// [A] Preservando o estado para um `reducer` que não foi carregado ainda
const combine = (reducers) => {
const reducerNames = Object.keys(reducers);
Object.keys(initialState).forEach(item => {
if (reducerNames.indexOf(item) === -1) {
reducers[item] = (state = null) => state;
}
});
return combineReducers(reducers);
};
const reducer = combine(reducerRegistry.getReducers());
const store = createStore(reducer, initialState);
// [B] Substitui a `store` atual toda vez que um `reducer` for registrado
reducerRegistry.setChangeListener(reducers => {
store.replaceReducer(combine(reducers));
});
export default store;

Com essa etapa de registro de reducers no Redux, temos uma maneira prática de gerenciar o estado da nossa store em uma aplicação que depende de code-splitting, deixando nosso código modularizado e isolado.

⭐ Créditos

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store