Existe um número considerável de soluções no mercado para construção de Single Page Applications e cada uma delas resolve questões arquiteturais de sua maneira. Todavia, Redux é um padrão para gerenciamento centralizado de estado da aplicação que vem ganhando muita atenção da comunidade. Esse padrão se tornou tão popular que foram criadas diversas bibliotecas para integrá-lo com frameworks SPA. Vejamos algumas delas:

Neste artigo aplicaremos na prática Redux com vanilla JavaScript. Essa abordagem minimalista nos ajudará a focar nos seus principais conceitos sem termos o peso cognitivo de bibliotecas que fazem a ponte entre Redux e determinado framework.

Vejamos a seguir a infraestrutura mínina que o leitor precisará para colocar em prática o que aprenderá.

Infraestrutura

A única infraestrutura necessária para que o leitor possa colocar em prática o código deste artigo é ter instalado em sua máquina a plataforma Node.js. A versão 8.9.4 foi utilizada por este autor.

Preparando um pequeno projeto

Para que possamos ver a arquitetura Redux em ação, criaremos um pequeno projeto que utilizará o básico do Webpack para que possamos carregar módulos baixados através do npm, o gerenciador de módulos do Node.js.

Primeiro, criaremos a pasta project e dentro dela o arquivo index.html:

<!-- project/index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width">
    <title>Redux with vanilla JavaScript</title>
</head>
<body>
    <input class="userName" placeholder="user name">
    <p class="status"></p>
    <script src="dist/bundle.js"></script>
</body>
</html>

É uma página simples que possui apenas um <input> no qual o usuário informará seu nome e um <p> que exibirá em tempo real uma mensagem de status contendo o nome informado. Por fim, a tag <script> carregará o bundle.js gerado através do Webpack. Esse bundle, além do nosso código, conterá a biblioteca padrão do Redux que utilizaremos.

Agora, dentro da pasta project/app vamos criar o módulo app.js com o script necessário para que o <p> seja atualizado a cada dígito do usuário em <input>:

// app/app.js
const statusParagraph = document.querySelector('.status');

document
.querySelector('.userName')
.oninput = e => {
    const userName = e.target.value;
    statusParagraph.textContent = userName 
        ? `${e.target.value} is typing` 
        : '';
};

Agora que já temos a estrutura mínima do projeto, vamos instalar e configurar rapidamente o Webpack para que possamos ver a aplicação em ação:

Instalando o Webpack e o Webpack Dev Server

No terminal e dentro da pasta project, vamos criar o arquivo package.json através do comando:

npm init -y

Com o package.json criado, baixaremos os módulos do Webpack e do Webpack Dev Server de uma só vez:

npm install webpack@3.11.0 webpack-dev-server@2.11.1 -D

Por fim, vamos criar o arquivo project/webpack.config.js com a configuração mínima que gerará o bundle.js:

// project/webpack.config.js
const path = require('path');

module.exports = {
    entry: './app/app.js',
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, 'dist'),
        publicPath: 'dist'
    }
}; 

Para terminar a configuração, adicionaremos em project/package.json o script "start", o responsável pela execução do webpack-dev-server que carregará as configurações definidas em webpack.config.js:

{
  "name": "project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "webpack-dev-server"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "webpack": "^3.11.0",
    "webpack-dev-server": "^2.11.1"
  }
}

Ainda dentro da pasta project, iniciamos nosso servidor através do comando:

npm start

Acessamos nossa aplicação através do endereço http://localhost:8080. Depois de carregada, experimente digitar no único <input> da página. Instantaneamente o valor digitado será exibido no <p> logo abaixo.

Alterações nos módulos da aplicação dispararão a geração de um novo bundle.js sem que o desenvolvedor tenha que se preocupar.

Antes de modificarmos nosso projeto para utilizar Redux, veremos brevemente seus principais princípios.

Sobre Redux

O próprio site do Redux o define como um container de estado previsível. Ele se baseia em três princípios:

  • SSOT (Single Source of Truth): o estado de toda a aplicação é armazenado em uma árvore de objetos dentro de uma única store. Ela é a única fonte de verdade sobre o estado da aplicação.
  • O estado é somente leitura: a única maneira de mudar o estado é emitindo uma action, um objeto que descreve o que aconteceu.
  • Mudanças são realizadas através de funções puras: essas funções puras são chamadas reducers. A partir de actions os reducers especificam como a árvore de estado deve ser transformada.

Uma função pura (pure function) é aquela que ao receber os mesmos argumentos retornará sempre o mesmo valor. Esse termo faz parte do jargão da programação funcional, inclusive funções desse tipo são fáceis de testar.

Não nos aprofundaremos nas definições, pois elas emergirão ao longo do nosso projeto. O autor entende que para compreender Redux é necessário conciliar teoria e prática. Aliás, chegou a hora de realizarmos a integração com Redux.

Instalando o módulo redux

Com o servidor parado, ainda na pasta project, vamos instalar o módulo redux:

npm install redux@3.7.2 -S

Lembre-se que ao utilizarmos Redux, teremos uma única store que armazenará o estado de toda a aplicação. Não poderia ser diferente, já que ela será nossa única fonte de verdade sobre seu estado. Vamos criar nossa store no módulo app/store.js:

app/store.js
// vazio, por enquanto

Por enquanto nosso módulo não terá qualquer código, porque seu estado inicial será definido através do único reducer que nossa simples aplicação terá. Aliás, uma aplicação pode ter um ou mais reducers que no final são combinados para criar a única store da aplicação. Vamos criar nosso primeiro reducer.

O papel do reducer

O reducer é o responsável em especificar como o estado da aplicação deve ser alterado em resposta às actions recebidas pela store. Vamos criar nosso primeiro reducer:

// app/reducers/statusReducer.js
const initialState = { status: '' };

export const statusReducer = (state = initialState, action) => {
    // ainda falta implementar
};

Devido à forte relação com reducers, a única store da aplicação é criada a partir da combinação de todos os reducers, pois cada um é o responsável por atualizar parte do estado da aplicação. Isso ficará mais claro quando criarmos nossa store.

A variável initialState é um objeto que representa o estado incial da aplicação sob a jurisdição do reducer. Esse objeto pode ter uma ou mais propriedades, em nosso caso, ele possui apenas a propriedade status, aquela que guardará a mensagem de status da aplicação. Seu valor inicial é uma string em branco.

Em seguida, definimos nossa função pura, isto é, nosso reducer que recebe sempre como parâmetro o estado da aplicação e a action recebida. Através de default parameter indicamos que o valor padrão do parâmetro state será initialState, aquele objeto que define o estado inicial da aplicação.

// app/reducers/statusReducer.js
const initialState = { status: '' };

export const statusReducer = (state = initialState, action) => {
    // Nova mensagem de status para ser atualizada 
    // no estado da aplicação, isto é, na store
    const status = action.payload;
};

Ainda não criamos actions em nossa aplicação, mas o mais importante nesse momento é entender que uma action nada mais é do que um objeto JavaScript com as propriedades type e payload. O primeiro guarda uma string que identifica o seu tipo, já o segundo o valor associado, isto é, aquele que desejamos que seja atualizado no estado da aplicação. É por isso que o valor de action.payload será a nova mensagem de status que desejamos atualizar na store.

Em breve, quando digitarmos no <input> da página, despacharemos para nossa store a action com o type ‘CHANGE_STATUS’ e que terá como payload o valor digitado. Todavia, lembre-se que é papel do reducer realizar a atualização do estado na store e não da action.

Como um reducer potencialmente pode lidar com uma ou mais actions, faremos um switch que decidirá qual lógica executar com base no tipo da action recebida:

// app/reducers/statusReducer.js
const initialState = { status: '' };

export const statusReducer = (state = initialState, action) => {
    
    const status = action.payload;

    switch (action.type) {
        case 'CHANGE_STATUS':
            // modificar o estado aqui
        default:
            return state;
    }
};

É importante que nosso reducer retorne o estado atual da aplicação caso a action disparada não tenha sido definida, por isso adicionamos a cláusula default em nosso switch.

Imutabilidade do estado da aplicação

Excelente, todavia chegamos a uma das partes mais importantes do Redux. O estado da aplicação deve ser imutável, isto é, não deve mudar. Para quem esta começando com Redux essa questão da imutabilidade pode dar um nó na cabeça. Quando dizemos imutável, significa que para modificarmos o estado da aplicação precisaremos criar um novo object state com base no state anterior.

Nesse sentido, uma abordagem imprópria na hora de atualizar o estado da aplicação é a seguinte:

// app/reducers/statusReducer.js
const initialState = { status: '' };

export const statusReducer = (state = initialState, action) => {
    
    const status = action.payload;

    switch (action.type) {
        case 'CHANGE_STATUS':
            // NÃO FAÇAM ISSO!
            state.status = status;
        default:
            return state;
    }
};

No código anterior, ocorreu a mutação do state da aplicação. Isto é, aproveitamos o mesmo objeto (referência) e modificamos sua propriedade status. Uma abordagem imutável é a seguinte:

// app/reducers/statusReducer.js
const initialState = { status: '' };

export const statusReducer = (state = initialState, action) => {
    
    const status = action.payload;

    switch (action.type) {
        case 'CHANGE_STATUS':
            return Object.assign({}, state, { status });
        default:
            return state;
    }
};

Se temos um artefato imutável, como realizamos alterações? Criamos um novo artefato e copiamos os valores do artefato atual, inclusive definindo novos valores para as propriedades que desejamos que sejam diferentes no artefato final.

A função Object.assign() é usada para copiar os valores de todas as propriedades próprias enumeráveis de um ou mais objetos de origem para um objeto destino. Este método irá retornar o objeto destino.

Em nosso caso, o primeiro parâmetro {} é o objeto destino, isto é, um novo objeto sem qualquer propriedade. Já os demais parâmetros são os objetos cujas propriedades serão copiadas para o objeto destino. Se um ou mais desses objetos tiverem a mesma propriedade, será o valor do último objeto passado como parâmetro que fará parte do objeto destino. É por isso que passamos { status } como último parâmetro.

A partir do ES2018 podemos usar o Spread Operator com propriedades de objeto. Isso nos permitirá substituir o verboso Object.assign({}, state, { status }) por { ...state, status }.

Construindo nossa store

Agora que já temos nosso reducer implementado, construiremos nossa store que o utilizará durante sua criação.

Vamos alterar o módulo app/store.js importando a função createStore do módulo redux e nosso statusReducer.

// app/store.js
import { createStore } from 'redux';
import { statusReducer } from './reducers/statusReducer';

export const store = createStore(statusReducer);

A função createStore recebeu como parâmetro o statusReducer para criar a nossa store. É a store criada que será exportada pelo módulo. Lembre-se, em Redux podemos ter vários reducers responsáveis em lidar com ações e atualizar determinados estados da aplicação, mas os estados ficam centralizados em um único lugar, a store.

Se tivéssemos mais de um reducer precisaríamos combiná-los para então passar o resultado da combinação para a função createStore. Esse processo de combinar reducers é feito através da função combineStores, também definida no módulo redux.

Agora que já temos a store pronta, partiremos para a implementação da única action da aplicação.

Implementando uma action

Já falamos um pouco sobre action, ela descreve o fato de que algo aconteceu, mas não define como o estado da aplicação deve ser alterado. A mudança do estado é feita sincronamente através de reducers. Chegou a hora de implementá-la em nossa aplicação.

Estruturalmente, uma action é definida através de um objeto com as propriedades type e payload. Por fim, elas são despachadas através da função dispatch da nossa store. Vejamos um exemplo isolado, que não entra em nossa aplicação:

// EXEMPLO apenas, não entra em nossa aplicação
// importa a store da aplicação
import { store } from './store.js';
// Solicita a store o depacho da action,
// pode ser disparado a partir de alguma 
// ação do usuário
store.dispatch({
    type: 'CHANGE_STATUS',
    payload: 'Calopsita is typing'
})

No código anterior, nosso reducer responderá à ação CHANGE_STATUS recebendo o valor do payload. É com base nesse valor que a store será atualizada. Um ponto a destacar é que efeitos colaterais (side effects) são realizados em actions e não em nossos reducers. Exemplos de side effects são chamadas a console.log() e requisições ajax. Ainda veremos como lidar com efeitos colaterais em nossas actions.

Todavia, a abordagem que adotamos deixa um pouco a desejar. Se uma action deste tipo é usada em diversos lugares da aplicação e de uma hora para outra precisarmos mudar seu tipo, teremos que alterar em diversos lugares. Uma solução para o problema é criarmos action creators.

Flexibilidade com Action Creators

Action creators são funções que ao serem invocadas retornam uma action. Vamos criar o módulo app/actions/status.js e dentro dele definir um action creator para a ação ‘CHANGE_STATUS’:

// app/actions/status.js
export const changeStatus = userName => {
    return {
        type: 'CHANGE_STATUS',
        payload: `${userName} is typing`
    }
};

O módulo app/actions/status.js exporta apenas um action creator que declaramos na função changeStatus. Essa função recebe como parâmetro um userName e ao ser chamada retorná uma action. Podemos simplificar ainda mais o código desta maneira:

// app/actions/status.js
export const changeStatus = userName => ({
    type: 'CHANGE_STATUS',
    payload: `${userName} is typing`
});

Agora, voltando ao exemplo isolado exibido anteriormente e fazendo uso do nosso action creator:

// EXEMPLO apenas, não entra em nossa aplicação
import { store } from './store.js';
// importou o action creator
import { changeStatus } from './actions/status.js';
// não sabemos mais os detalhes da action!
store.dispatch(changeStatus('Calopsita is typing'));

Excelente, escondemos os detalhes da action 'CHANGE_STATUS'. Todavia, nosso código pode ficar ainda melhor.

Action Types como constantes

Se analisarmos os módulos app/reducers/statusReducer.js e app/actions/status.js veremos que cada um deles define a string CHANGE_STATUS. Além de termos o nome duplicado, nada impede o programador de acidentalmente escrever o tipo da action errado.

Para solucionar o problema que acabamos de ver, vamos declarar os tipos das actions como constantes em um módulo em separado chamado app/constants/actionTypes.js e usar esse módulo em todos os lugares que os tipos forem necessários:

// app/constants/actionTypes.js
export const actionTypes = {
    CHANGE_STATUS: 'CHANGE_STATUS'
};

Utilizando a constante em app/actions/status.js:

// app/actions/status.js
// importa as constantes
import { actionTypes } from '../constants/actionTypes.js';

export const changeStatus = userName => ({
    // utiliza a constante
    type: actionTypes.CHANGE_STATUS,
    payload: `${userName} is typing`
});

E também no módulo app/reducers/statusReducer.js:

// app/reducers/statusReducer.js
// importa as constantes
import { actionTypes } from '../constants/actionTypes';

const initialState = { status: '' };

export const statusReducer = (state = initialState, action) => {
    
    const status = action.payload;

    switch (action.type) {
        // utiliza a constante
        case actionTypes.CHANGE_STATUS:
            return Object.assign({}, state, { status });
        default:
            return state;
    }
};

Nosso código ficou mais organizado e menos sujeito a erro.

A criação das pastas reducers, actions e constants segue o padrão Rails-Style. Você pode consultar outras formas de estruturar um projeto na própria documentação do Redux.

Temos nossa store (criada a partir de statusReducer) e nosso action creator changeStatus utilizando uma constante em comun definida em actionTypes. Chegou a hora de realizamos mudanças no estado da aplicação despachando ações através das ações do usuário.

Despachando actions

Vamos importar nossa store e o action creator changeStatus no módulo app/app.js. Despachamos atualizações no estado da aplicação através da função store.dispatch(). Ela recebe a action retornada pelo action creator changestatus. Lembre-se que isso chegará na cláusula switch de statusReducer, o reducer que foi utilizado para criar nossa store:

// app/app.js
import { store } from './store';
import { changeStatus } from './actions/status';

const statusParagraph = document.querySelector('.status');
document
.querySelector('.userName')
.oninput = e => {
    const userName = e.target.value;
    // changeStatus retorna uma action 
    // que é passada para store.dispatch
    store.dispatch(changeStatus(userName));
};

Excelente, mas como saberemos que o estado da aplicação mudou? Precisamos dessa informação para que possamos atualizar nossa página. Fazemos isso através da função store.subscribe(). Ela será chamada toda vez que a nossa store mudar:

// app/app.js
import { store } from './store';
import { changeStatus } from './actions/status';

const statusParagraph = document.querySelector('.status');
document
.querySelector('.userName')
.oninput = e => {
    const userName = e.target.value;
    store.dispatch(changeStatus(userName));
};

store.subscribe(() => {
    // obtemos o estado atual, depois da modificação
    const state = store.getState();
    // atualizamos o elemento com o novo valor
    statusParagraph.textContent = state.status;
});

Em uma aplicação com React, os componentes são conectados à store através da função connect do módulo react-redux, aquele que realiza uma ponte entre o Redux e o React, dispensando o uso de store.subscribe.

Quando o estado da aplicação for alterado, o callback passado para store.subscribe() será chamado. Nele, acessamos o estado atual da aplicação através de store.getState() para que possamos atualizar o elemento do DOM com valor atualizado de state.status.

Trabalhando com middlewares

Nosso código é funcional, inclusive podemos testar no navegador, mas para transcendermos nosso entendimento sobre o que esta acontecendo podemos ativar o middleware redux-logger. O redux-logger nos permitirá visualizar as actions que chegam aos reducers, inclusive o estado da aplicação antes e depois de modificado.

Ainda sobre middleware, seu conceito é o mesmo empregado no framework web Express.js. Nele, adicionamos um ou mais middlewares em sua pilha de middlewares e cada um deles lidará com a requisição passando o controle para o próximo middleware da pilha.

No caso do Redux, seus middlewares permitem adicionar um código específico entre o despacho da action e o momento que ela chega ao reducer.

Dentro da pasta project, vamos instalar o middleware:

npm install redux-logger@3.0.6 -S

Agora, em app/store.js vamos importar a função applyMiddeware do módulo redux. Essa função recebe uma quantidade indeterminada de middlewares que desejamos adicionar em uma pilha. Em seguida, vamos importar createLogger do módulo redux-logger para então ativá-lo em nossa store:

import { createStore, applyMiddleware } from 'redux';
import { statusReducer } from './reducers/statusReducer';
import { createLogger  } from 'redux-logger';

const middlewares = applyMiddleware(createLogger());
export const store = createStore(statusReducer, middlewares);

O segundo parâmetro de createStore é a pilha de middlewares que desejamos aplicar. Em nosso caso, teremos um niddleware apenas, o createLogger.

Agora, no navegador, com o aba do console aberta, podemos verificar que a cada dígito no <input> é exibida informações valiosas sobre o que esta ocorrendo com o estado da aplicação.

Lidando com o operações assíncronas

Vimos que side effects devem ser realizados em nossas actions e nunca dentro dos nossos reducers. Todavia, como faremos para lidar com operações assíncronas, por exemplo, requisições ajax? Nesse caso, nossos actions creator precisarão ser declarados de uma maneira especial.

Vamos simular uma operação assíncrona através de um setTimeout facilitando assim nosso teste sem dependermos de uma API para tal tarefa.

Nosso módulo app/actions/status.js ficará assim:

import * as types from '../constants/actionTypes.js';

export const changeStatus = text => {

    return dispatch => {

        setTimeout(() => dispatch(
            {
                type: types.CHANGE_STATUS,
                payload: text
            }
        ), 1000);
    };
};

O mesmo código pode ser reescrito dessa forma:

import * as types from '../constants/actionTypes.js';

export const changeStatus = text => 
    dispatch =>
        setTimeout(() => 
            dispatch({
                type: types.CHANGE_STATUS,
                payload: text
            })
        , 1000);

Nosso action creator não retornará mais um objeto, ele retornará uma função que ao ser chamada nos dará acesso ao dispatch. Sendo assim, como estamos retornando uma função, podemos executar dentro dela uma operação assíncrona. Quando ela terminar, despachamos a ação. No entanto, para que o código acima funcione, precisamos de um middleware especial. No caso, utilizaremos o redux-thunk.

Vamos instalá-lo através do terminal, ainda dentro da pasta project:

npm install redux-thunk@2.2.0 -S

Como todo middleware, ele precisa ser registrado em nossa store:

// project/store.js
import { createStore, applyMiddleware } from 'redux';
import { statusReducer } from './reducers/statusReducer';
import { createLogger  } from 'redux-logger';
// importou o novo middleware
import thunk from 'redux-thunk';
// adicionou o middleware thunk
const middlewares = applyMiddleware(thunk, createLogger());
export const store = createStore(statusReducer, middlewares);

Pronto. Basta recarregarmos nossa aplicação para verificarmos que tudo continua funcionando. Ao digitarmos no <input>, depois de um intervalo de tempo o username digitado será exibido no <p> como antes.

Time travel debugging

Através do Redux DevTools, uma extensão do Chrome, podemos gravar actions despachadas e o estado da store. Nessa linha de tempo, podemos verificar o estado da aplicação e viajar de volta no tempo para um estado anterior sem recarregarmos a página. Esse técnica de depuração é chamada de time travel debugging.

Não basta termos instalado o Redux DevTools no Google Chrome, precisamos realizar uma pequena configuração em nossa store para ligá-la à ferramenta de depuração.

Vamos alterar o módulo app/store.js:

// importou a função compose
import { createStore, applyMiddleware, compose } from 'redux';
import { statusReducer } from './reducers/statusReducer';
import { createLogger  } from 'redux-logger';
import thunk from 'redux-thunk';

// configuração especial
const middlewares = compose(
    applyMiddleware(thunk, createLogger()),
    window.devToolsExtension ? window.devToolsExtension() : f => f
);

export const store = createStore(statusReducer, middlewares);

A função compose é um utilitário para programação funcional e foi adicionada no módulo redux para facilitar a vida do desenvolvedor. O autor já falou sobre composição no artigo “Compondo funções em JavaScript”.

No código anterior, realizamos a composição do resultado da função applyMiddleware com um trecho de código que se conectará ao React DevTools caso ele tenha sido instalado como extensão do Chrome.

Vamos reduzir a tela do Chrome e reservar um espaço à esquerda. É nesse espaço que trabalharemos com o console do Redux DevTools. Para ativá-lo, vamos clicar com o botão direito na página e escolher a opção Redux DevTools->to Left.

Painel do Redux DevTools


Vamos digar duas letras no <input> da página. Inspecionando o console vemos as seguintes informações:

Actions ao longo do tempo


Visualizamos as actions disparadas em ordem cronológica, inclusive podemos verificar a mudança de estado da aplicação. Clicando na action a opção jump será exibida e, quando clicada, fará com que o estado da aplicação volte para o estado de quando aquela action foi executada:

Time travel debugging


É um recurso incrível que permite depurar a aplicação sem termos que reiniciá-la ou repetirmos determinada action manualmente através da interface da aplicação.

Dan Abramovich e uma proposta para substituir o Redux, o Future-Fetcher

Durante a JSConf 2018, Dan Abramovich, o criador do Redux, anunciou um substituto para Redux chamado Future-Fetcher. Pouco se sabe sobre sua implementação, a não ser que foi motivada pela debanda de desenvolvedores do Redux para soluções menos complexas como MobX. O objetivo de Dan com o Future-Fetcher é trazer de volta a simplicidade no gerenciamento de estado na plataforma JavaScript. Com certeza é algo que o leitor deve ficar de olho e que não esta muito longe de se concretizar.

Código no Github

Você encontra o código completo deste artigo no meu github.

Conclusão

O conceito do Redux é simples, o que pode complicar seu entendimento é a quantidade de artefatos envolvidos durante sua implementação e a relação entre eles. Frameworks do mercado oferecem módulos especializados para realizar a ponte entre eles e o módulo redux. E você? Já utiliza Redux em suas aplicações? Ele trouxe benefícios? Tornou mais complexo o projeto? Deixe sua opinião.