Lidando com LocalStorage no Redux

Continuando o nosso artigo sobre localStorage onde nós criamos um hook para lidar com ele, hoje vamos dar uma olhada sobre como trabalhar com esse recurso só que em vez de ser em um hook, será utilizando a biblioteca Redux.

O que é o Redux?

O Redux é uma biblioteca que serve para gerenciarmos o estado da nossa aplicação, afinal gerenciar ele só com hooks como o useState pode até ser algo tranquilo quando estamos usando poucos estados, por exemplo ao criar um contador de clicks ou manipular um formulário com poucos campos, porém pode se tornar uma tarefa complicada lidar com o estado de uma aplicação conforme ela vai crescendo e se tornando mais complexa, afinal temos que lidar com estados assíncronos, estados compartilhados por múltiplos componentes, transformações complexas desses estados, etc. E é exatamente nisso que o Redux nos auxilia, ele é uma biblioteca que facilita em muito todas essas dificuldades que podemos ter ao lidar com estados complexos da nossa aplicação.

O problema de se usar LocalStorage em um Reducer

Ele trabalha com o conceito de reducers, que são função que vão aplicar transformações nos dados baseado na ação dispachada para elas, porém uma regra reforçada pela própria documentação (embora um dos criadores da lib discorde) é que os reducers devem ser funções puras – ou seja livres de efeitos colaterais (requisições http, I/O, lidar com console, alterar variável, etc) – e é aqui que notamos o primeiro problema ao tentar trabalhar com LocalStorage utilizando o Redux: LocalStorage é um efeito colateral, e dos grandes.

Como o Redux lida com efeitos colaterais?

Se você for atento, vai se lembrar que eu citei no início que o Redux consegue nos auxiliar mesmo quando temos estados que lidam com requisições assíncronas que também são efeitos colaterais pesadíssimos (bem mais perigosos que o LocalStorage), logo ele deve ter uma forma de lidar com eles, certo? E você está correto caro leitor, o Redux tem uma forma de lidar com efeitos colaterais ao dispachar efeitos colaterais nos reducers que são os Middlewares.

Basicamente um Middleware é um pedaço de código (no nosso caso uma função) que executa entre um código e outro (você pode pensar como se fosse um código executado entre as rotas e os controllers em um framework de back-end, ou no nosso caso o código que é executado antes do reducer ser executado, e depois que ele é executado). Nos middlewares podemos fazer de tudo até mesmo efeitos colaterais, uma vez que eles não afetam o resultado final da execução de um reducer.

Soluções para LocalStorage utilizando Redux

Com isso temos algumas soluções possíveis para o uso de LocalStorage com Redux:

  • Lidar com os efeitos colaterais em um reducer por meio de um Middleware;
  • Lidar de forma reativa, utilizando o método subscribe da store;

E vamos abordar essas soluções nas seções a seguir.

Definindo as bases do nosso exemplo

Antes de nós prosseguirmos para as implementações de fato de ambas as soluções, vamos definir um exemplo para podermos trabalhar em cima durante as implementações sub-sequentes.

O que nós vamos montar é um contador, que mantém a sua contagem mesmo após recerregar a página (assim podemos fazer uso do localStorage para persistir o estado da nossa aplicação).

Então primeiro vamos dar uma olhada na estrutura de pastas do nosso exemplo (o projeto base foi criado através do vite):

src/
├─ app/
│  ├─ middlewares/
│  │  └─ local-storage.ts
│  ├─ local-storage-state.ts
│  └─ store.ts
│
├─ hooks/
│  └─ redux/
│     ├─ index.ts
│     ├─ use-app-dispatch.ts
│     └─ use-app-selector.ts
│
├─ modules/
│ ├─ counter/
│ │  └─ components/
│ │     ├─ counter.tsx
│ │     └─ index.ts
│ └─ slice.ts
│
├─ App.tsx
└─ main.tsx

A pasta app vai conter os códigos relacionados a store do redux, por isso deixamos os middlewares e o arquivo de manipulação do localStorage (local-storage-state.ts) ali. É aqui que nós vamos trabalhar ao longo dos nossos tutoriais.

A pasta hooks contém os hooks tipados conforme o exemplo da documentação do redux.

E a pasta modules é onde nós vamos colocar os códigos das coisas relacionadas ao nosso contador (que vai ser o único módulo/feature do nosso exemplo), ou seja o código do nosso componente (counter.tsx) e também o da slice (slice.ts) criada pelo createSlice do Redux Toolkit.

O código da slice é bem simples, contendo actions para aumentar e diminuir o valor do contador, além de uma para redefini-lo para o seu valor original:

// @filename: slice.ts
import { createSlice } from "@reduxjs/toolkit";

const initialState = { value: 0 };
export const counterSlice = createSlice({
  name: "counter",
  initialState,
  reducers: {
    increment(state) {
      state.value++;
    },
    decrement(state) {
      state.value--;
    },
    reset() {
      return initialState;
    },
  },
});

export const { increment, decrement, reset } = counterSlice.actions;
export default counterSlice.reducer;

E o componente possui dois botões e um elemento no meio para podermos ver a contagem atual:

// @filename: counter.tsx
import { useAppDispatch, useAppSelector } from "../../../hooks/redux";
import { increment, decrement } from "../slice";

const Counter = () => {
  const dispatch = useAppDispatch();
  const count = useAppSelector(({ counter }) => counter.value);

  return (
    <div className="counter">
      <button onClick={() => dispatch(increment())}>
        +
      </button>
      <span>{count}</span>
      <button onClick={() => dispatch(decrement())}>
        -
      </button>
    </div>
  );
};

export default Counter;

Nós usamos ele no App.tsx dessa forma aqui:

// @filename: App.tsx
import { useAppDispatch } from "./hooks/redux";
import { Counter } from "./modules/counter/components";
import { reset } from "./modules/counter/slice";

const App = () => {
  const dispatch = useAppDispatch();

  return (
    <main className="wrapper">
      <Counter />
      <hr />
      <div>
        <button onClick={() => dispatch(reset())}>Reset count</button>
      </div>
    </main>
  );
};

export default App;

E para fechar vamos dar uma olhada no código que gerencia o salvamento e o carregamento do estado da nossa store dentro do LocalStorage:

// @filename: local-storage-state.ts
const key = "state";

export const save = <T>(state: T) => {
  try {
    localStorage.setItem(key, JSON.stringify(state));
  } catch {}
};

export const load = <T>(): T | undefined => {
  try {
    const serializedState = localStorage.getItem(key);
    if (serializedState === null) return undefined;
    return JSON.parse(serializedState) as T;
  } catch {
    return undefined;
  }
};

A lógica é bem semelhante ao que fizemos no nosso hook customizado, no save a gente tenta salvar o estado em uma chave chamada state – que é definida pela variável key, e se não der certo ele não faz nada.

No caso de carregar as informações, a gente tenta pegar o item do LocalStorage, e se der algum erro ou ele não existir, nós retornamos undefined (equivalente a dizer que a função não vai retornar nada), e se tiver a gente retorna o valor desserializado que estava no LocalStorage, que vai corresponder ao estado da nossa store que foi salvo lá pela função save.

Middlewares

Vamos começar resolvendo esse problema por meio de um middleware customizado, o código dele vai ser esse aqui:

import { Middleware } from "redux";
import { save } from "../local-storage-state";

const localStorage: Middleware = (store) => (next) => (action) => {
  const response = next(action);
  save(store.getState());
  return response;
};

export default localStorage;

Aqui nós definimos um middleware utilizando a técnica de currying (que abordaremos futuramente em outro artigo), conforme explicado na documentação, para termos acesso a store, ao dispatch (que é o parâmetro next), e a action. O nosso middleware é relativamente simples, nós finalizamos a execução da ação ao executar o next com ela como parâmetro, dessa forma o estado da nossa store já estará atualizado com base na action executada, ai nós salvamos o estado atual da store no LocalStorage utilizando a função save que nós tinhamos definido anteriormente.

E depois retornamos o resultado da execução do next, para que o redux consiga executar o próximo middleware (se ele existir) sem problemas.

A ideia aqui é que toda vez que uma ação for disparada, nós usamos esse middleware para salvar o estado atual da nossa store no LocalStorage, e usar a função load para carregar esse estado toda vez que a página for carregada. Podemos fazer isso tudo no arquivo de configuração da nossa store:

// @filename: store.ts
import { configureStore } from "@reduxjs/toolkit";
import counter from "../modules/counter/slice";
import { load } from "./local-storage-state";
import localStorage from "./middlewares/local-storage";

const store = configureStore({
  reducer: { counter },
  middleware: [localStorage],
  preloadedState: load(),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

Aqui nós passamos o nosso middleware na propriedade middleware do configureStore, e ele já estará em efeito, dessa forma toda ação que for executada irá salvar o estado da store no LocalStorage, e só vai faltar carregar o estado do LocalStorage toda vez que a página for inicializada, e é por isso que passamos o resultado da execução da função load para a opção preloadedState do configureStore.

Utilizando o subscribe

Bom, apesar de utilizar middlewares ser o jeito mais comum de se lidar com localStorage, ainda existe um outro jeito de se lidar com localStorage que é aproveitar as capacidades reativas de um dos métodos mais primitivos do redux: o subscribe.

Essa é uma abordagem ensinada inclusive por Dan Abramov (co-criador do Redux) em versões mais antigas do Redux, então acredito que valha a pena dedicar um pouco de tempo para citá-la neste artigo.

A ideia aqui é que a store, que é onde todo o estado da nossa aplicação reside no Redux, é um objeto que é um Observable, logo podemos observar as mudanças nele por meio do método subscribe, assim toda vez que o estado mudar, nós salvamos ele no LocalStorage, e então usariamos o estado armazenado ali como estado inicial para inicializar a nossa store toda vez que a página fosse carregada.

E se você vem prestando atenção vai notar que a lógica é basicamente a mesma da explicação baseada em middlewares, porém ao invés de termos o trabalho de criar um middleware e configurarmos tudo durante a criação da store, nesse caso, nós vamos adicionar essa funcionalidade após termos criado a store. E tudo que vamos ter que fazer em comparação com o exemplo anterior é modificar o arquivo store.ts:

import { configureStore } from "@reduxjs/toolkit";
import counter from "../modules/counter/slice";
import { load, save } from "./local-storage-state";

const store = configureStore({
  reducer: { counter },
  preloadedState: load(),
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

store.subscribe(() => save(store.getState()));
export default store;

Dessa forma ao invés de utilizarmos o save em um middleware, nós usamos ele direto no subscribe.

A vantagem dessa abordagem é a praticidade, porém a desvantagem é que fica mais dificil decidir como modularizar isso, coisa que é mais fácil de decidir usando middlewares, fora o fato de middlewares poderem se compor através da propriedade middleware do configureStore.

Conclusões

Lidar com LocalStorage no Redux não é uma tarefa fácil, mas esperamos que após este artigo você consiga fazer bom uso do que aprendeu aqui para atingir esse objetivo, aproveite a seção de comentários abaixo para nos contar qual das duas soluções foi a sua preferida, caso queira aprender mais sobre React e Redux considere se inscrever no nosso curso, e esperamos você no nosso próximo artigo.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *