A Evolução do Gerenciamento de Estado no React: Do Local ao Async

20 de Agosto, 20249 min de leitura
A Evolução do Gerenciamento de Estado no React: Do Local ao Async
#react#redux#webdev#typescript

Índice

Introdução

Olá!

Este artigo apresenta uma visão geral de como o State era gerenciado em Aplicações React há milhares de anos, quando Class Components dominavam o mundo e functional components eram apenas uma ideia ousada, até os tempos recentes, quando um novo paradigma de State emergiu: Estado Async.

Estado Local

Certo, todos que já trabalharam com React sabem o que é um Estado Local.

{% details Eu não sei o que é %} State Local é o estado de um único Componente.

Toda vez que um state é atualizado, o componente re-renderiza. {% enddetails %}

Você pode ter trabalhado com esta estrutura antiga:

class CommitList extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      isLoading: false,
      commits: [],
      error: null
    };
  }

  componentDidMount() {
    this.fetchCommits();
  }

  fetchCommits = async () => {
    this.setState({ isLoading: true });
    try {
      const response = await fetch('https://api.github.com/repos/facebook/react/commits');
      const data = await response.json();
      this.setState({ commits: data, isLoading: false });
    } catch (error) {
      this.setState({ error: error.message, isLoading: false });
    }
  };

  render() {
    const { isLoading, commits, error } = this.state;

    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error}</div>;

    return (
      <div>
        <h2>Commit List</h2>
        <ul>
          {commits.map(commit => (
            <li key={commit.sha}>{commit.commit.message}</li>
          ))}
        </ul>
        <TotalCommitsCount count={commits.length} />
      </div>
    );
  }
}

class TotalCommitsCount extends Component {
  render() {
    return <div>Total commits: {this.props.count}</div>;
  }
}
}

Talvez uma funcional moderna:

const CommitList = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [commits, setCommits] = useState([]);
  const [error, setError] = useState(null);

  // Para atualizar o state você pode usar setIsLoading, setCommits ou setUsername.
  // Como cada função irá sobrescrever apenas o state vinculado a ela.
  // NOTA: Ainda assim causará um re-render completo do componente
  useEffect(() => {
    const fetchCommits = async () => {
      setIsLoading(true);
      try {
        const response = await fetch('https://api.github.com/repos/facebook/react/commits');
        const data = await response.json();
        setCommits(data);
        setIsLoading(false);
      } catch (error) {
        setError(error.message);
        setIsLoading(false);
      }
    };

    fetchCommits();
  }, []);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
      <h2>Commit List</h2>
      <ul>
        {commits.map(commit => (
          <li key={commit.sha}>{commit.commit.message}</li>
        ))}
      </ul>
      <TotalCommitsCount count={commits.length} />
    </div>
  );
};

const TotalCommitsCount = ({ count }) => {
  return <div>Total commits: {count}</div>;
};

Ou até uma "mais aceita"? (Definitivamente mais rara)

const initialState = {
  isLoading: false,
  commits: [],
  userName: ''
};

const reducer = (state, action) => {
  switch (action.type) {
    case 'SET_LOADING':
      return { ...state, isLoading: action.payload };
    case 'SET_COMMITS':
      return { ...state, commits: action.payload };
    case 'SET_USERNAME':
      return { ...state, userName: action.payload };
    default:
      return state;
  }
};

const CommitList = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { isLoading, commits, userName } = state;

  // Para atualizar o state, use dispatch. Por exemplo:
  // dispatch({ type: 'SET_LOADING', payload: true });
  // dispatch({ type: 'SET_COMMITS', payload: [...] });
  // dispatch({ type: 'SET_USERNAME', payload: 'newUsername' });
};

O que pode te fazer pensar...

Por que diabos eu estaria escrevendo este reducer complexo para um único componente?

Bem, React herdou este hook feio chamado useReducer de uma ferramenta muito importante chamada Redux.

Se você já teve que lidar com Gerenciamento de Estado Global no React, você deve ter ouvido falar sobre Redux.

Isso nos leva ao próximo tópico: Gerenciamento de Estado Global.

Estado Global

Gerenciamento de Estado Global é um dos primeiros assuntos complexos ao aprender React.

O que é?

Pode ser múltiplas coisas, construídas de muitas maneiras, com diferentes bibliotecas.

Eu gosto de definir como:

const globalState = {
  isUnique: true,
  isAccessible: true,
  isModifiable: true,
  isFEOnly: true
}

Eu gosto de pensar nele como:

Isso mesmo, um Banco de Dados. É onde você armazena dados da aplicação, que seus componentes podem ler/escrever/atualizar/deletar.

Eu sei, por padrão, o state será recriado sempre que o usuário recarregar a página, mas isso pode não ser o que você quer que ele faça, e se você estiver persistindo dados em algum lugar (como o localStorage), você pode querer aprender sobre migrations para evitar quebrar a aplicação a cada novo deploy.

Eu gosto de usá-lo como:

Como usar?

O jeito principal

Redux

É o padrão da indústria.

Trabalhei com React, TypeScript e Redux por 7 anos. Todos os projetos com os quais trabalhei profissionalmente usam Redux.

A grande maioria das pessoas que conheci que trabalham com React, usam Redux.

A ferramenta mais mencionada em vagas abertas de React no Trampar de Casa é Redux.

A ferramenta de Gerenciamento de Estado React mais popular é...

Tambor

Redux

Github Stars of React State Management Tools

Se você quer trabalhar com React, você deveria aprender Redux. Se você trabalha atualmente com React, você provavelmente já sabe.

Ok, aqui está como geralmente fazemos fetch de dados usando Redux.

{% details Aviso %} "O quê? Isso faz sentido? Redux é para armazenar dados, não para fazer fetch, como diabos você faria fetch de dados com Redux?"

Se você pensou nisso, devo dizer:

Não estou realmente fazendo fetch de dados com Redux. Redux será o armário para a aplicação, ele armazenará sapatos states que estão diretamente relacionados ao fetching, é por isso que usei esta frase errada: "fetch de dados usando Redux". {% enddetails %}

// actions
export const SET_LOADING = 'SET_LOADING';
export const setLoading = (isLoading) => ({
  type: SET_LOADING,
  payload: isLoading,
});

export const SET_ERROR = 'SET_ERROR';
export const setError = (isError) => ({
  type: SET_ERROR,
  payload: isError,
});

export const SET_COMMITS = 'SET_COMMITS';
export const setCommits = (commits) => ({
  type: SET_COMMITS,
  payload: commits,
});


// Para poder usar action ASYNC, é necessário usar redux-thunk como middleware
export const fetchCommits = () => async (dispatch) => {
  dispatch(setLoading(true));
  try {
    const response = await fetch('https://api.github.com/repos/facebook/react/commits');
    const data = await response.json();
    dispatch(setCommits(data));
    dispatch(setError(false));
  } catch (error) {
    dispatch(setError(true));
  } finally {
    dispatch(setLoading(false));
  }
};

// o state compartilhado entre 2-a-muitos componentes
const initialState = {
  isLoading: false,
  isError: false,
  commits: [],
};

// reducer
export const rootReducer = (state = initialState, action) => {
  // Isso também poderia ser actions[action.type].
  switch (action.type) {
    case SET_LOADING:
      return { ...state, isLoading: action.payload };
    case SET_ERROR:
      return { ...state, isError: action.payload };
    case SET_COMMITS:
      return { ...state, commits: action.payload };
    default:
      return state;
  }
};

Agora do lado da UI, integramos com actions usando useDispatch e useSelector:

// Commits.tsx
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchCommits } from './action';

export const Commits = () => {
  const dispatch = useDispatch();
  const { isLoading, isError, commits } = useSelector(state => state);

  useEffect(() => {
    dispatch(fetchCommits());
  }, [dispatch]);

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error while trying to fetch commits.</div>;

  return (
    <ul>
      {commits.map(commit => (
        <li key={commit.sha}>{commit.commit.message}</li>
      ))}
    </ul>
  );
};

Se Commits.tsx fosse o único componente que precisasse acessar a lista de commits, você não deveria armazenar estes dados no Estado Global. Poderia usar o estado local em vez disso.

Mas vamos supor que você tenha outros componentes que precisam interagir com esta lista, um deles pode ser tão simples quanto este:

// TotalCommitsCount.tsx
import React from 'react';
import { useSelector } from 'react-redux';

export const TotalCommitsCount = () => {
  const commitCount = useSelector(state => state.commits.length);
  return <div>Total commits: {commitCount}</div>;
}

{% details Aviso %} Em teoria, este pedaço de código faria mais sentido vivendo dentro de Commits.tsx, mas vamos assumir que queremos exibir este componente em múltiplos lugares da aplicação e faz sentido colocar a lista de commits no Estado Global e ter este componente TotalCommitsCount. {% enddetails %}

Com o componente index.js sendo algo assim:

import React from 'react';
import ReactDOM from 'react-dom';
import thunk from 'redux-thunk';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import { Commits } from "./Commits"
import { TotalCommitsCount } from "./TotalCommitsCount"

export const App = () => (
	<main>
		<TotalCommitsCount />
		<Commits />
	</main>
)

const store = createStore(rootReducer, applyMiddleware(thunk));
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Isso funciona, mas cara, parece excessivamente complicado para algo tão simples quanto fazer fetch de dados, certo?

Redux parece um pouco inchado demais para mim.

Você é forçado a criar actions e reducers, frequentemente também precisa criar um nome de string para a action ser usada dentro do reducer, e dependendo da estrutura de pastas do projeto, cada camada poderia estar em um arquivo diferente.

O que não é produtivo.

Mas espere, há um jeito mais simples.

O jeito simples

Zustand

No momento em que escrevo este artigo, Zustand tem 3.495.826 milhões de downloads semanais, mais de 45.000 estrelas no GitHub, e 2, isso mesmo, DOIS Pull Requests abertos.

UM DELES É SOBRE ATUALIZAR A DOC Zustand Open Issues

Se isso não é uma obra de arte de Programação de Software, eu não sei o que é.

Aqui está como replicar o código anterior usando Zustand.

// store.js
import create from 'zustand';

const useStore = create((set) => ({
  isLoading: false,
  isError: false,
  commits: [],
  fetchCommits: async () => {
    set({ isLoading: true });
    try {
      const response = await fetch('https://api.github.com/repos/facebook/react/commits');
      const data = await response.json();
      set({ commits: data, isError: false });
    } catch (error) {
      set({ isError: true });
    } finally {
      set({ isLoading: false });
    }
  },
}));

Esta foi nossa Store, agora a UI.

// Commits.tsx
import React, { useEffect } from 'react';
import useStore from './store';

export const Commits = () => {
  const { isLoading, isError, commits, fetchCommits } = useStore();

  useEffect(() => {
    fetchCommits();
  }, [fetchCommits]);

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error occurred</div>;

  return (
    <ul>
      {commits.map(commit => (
        <li key={commit.sha}>{commit.commit.message}</li>
      ))}
    </ul>
  );
}

E por último, mas não menos importante.

// TotalCommitsCount.tsx
import React from 'react';
import useStore from './store';
const TotalCommitsCount = () => {
	const totalCommits = useStore(state => state.commits.length);
	return (
		<div>
			<h2>Total Commits:</h2> <p>{totalCommits}</p>
		</div>
	);
};

Não há actions e reducers, há uma Store.

E é aconselhável ter slices da Store, então tudo fica perto da feature relacionada aos dados.

Funciona perfeitamente com uma estrutura de pastas folder-by-feature. Chef Kiss Emoji

O jeito errado

Preciso confessar algo, ambos meus exemplos anteriores estão errados.

E deixe-me fazer um aviso rápido: Eles não estão errados, eles estão desatualizados, e portanto, errados.

Isso nem sempre foi errado. Era assim que costumávamos desenvolver fetch de dados em aplicações React há algum tempo, e você ainda pode encontrar código similar a este por aí no mundo.

Mas há outro jeito.

Um mais fácil, e mais alinhado com uma característica essencial para desenvolvimento web: Caching. Mas voltarei a este assunto mais tarde.

Atualmente, para fazer fetch de dados em um único componente, o seguinte fluxo é necessário: Fetching data flow

O que acontece se eu precisar fazer fetch de dados de 20 endpoints dentro de 20 componentes?

  • 20x isLoading + 20x isError + 20x actions para mutar estas propriedades.

Como eles ficarão?

Com 20 endpoints, isso se tornará um processo muito repetitivo e causará uma boa quantidade de código duplicado.

E se você precisar implementar uma feature de caching para prevenir chamar novamente o mesmo endpoint em um curto período? (ou qualquer outra condição)

Bem, isso se traduzirá em muito trabalho para features básicas (como caching) e componentes bem escritos que estão preparados para estados de loading/error.

É por isso que Estado Async nasceu.

Estado Async

Antes de falar sobre Estado Async quero mencionar algo. Sabemos como usar Estado Local e Global, mas neste momento não mencionei o que deveria ser armazenado e por quê.

O exemplo de Estado Global tem uma falha e uma importante.

O componente TotalCommitsCount sempre exibirá a Contagem de Commits, mesmo se estiver carregando ou tiver um erro.

Se a requisição falhou, não há como saber que a Contagem Total de Commits é 0, então apresentar este valor é apresentar uma mentira.

De fato, até a requisição terminar, não há como saber com certeza qual é o valor da Contagem Total de Commits.

Isso é porque a Contagem Total de Commits não é um valor que temos dentro da aplicação. É informação externa, coisas async, você sabe.

Não devemos estar contando mentiras se não sabemos a verdade.

É por isso que devemos identificar Estado Async em nossa aplicação e criar componentes preparados para isso.

Podemos fazer isso com React-Query, SWR, Redux Toolkit Query e muitos outros.

Github Stars of React Async State Tools

Para este artigo, usarei React-Query.

Eu recomendo que você acesse a documentação de cada uma destas ferramentas para entender melhor quais problemas elas resolvem.

Aqui está o código: {% embed https://gist.github.com/ocodista/0b160d6d529ce60f1fd36fa566513a80.js %}

Não mais actions, não mais dispatches, não mais Estado Global para fazer fetch de dados.

Isso é o que você tem que fazer no seu arquivo App.tsx para ter o React-Query corretamente configurado:

Veja, Estado Async é especial.

É como o gato de Schrödinger - você não sabe o estado até observá-lo (ou executá-lo).

Mas espere, se ambos os componentes estão chamando useCommits e useCommits está chamando um endpoint de API, isso significa que haverá DUAS requisições idênticas para carregar os mesmos dados?

Resposta Curta: não!

Resposta Longa: React Query é incrível. Ele automaticamente lida com esta situação para você, vem com caching pré-configurado que é inteligente o suficiente para saber quando fazer refetch dos seus dados ou simplesmente usar o cache.

Também é extremamente configurável para que você possa ajustar para atender 100% das necessidades da sua aplicação.

Agora temos nossos componentes sempre prontos para isLoading ou isError e mantemos o Estado Global menos poluído e temos algumas features bem legais prontas para uso.

Conclusão

Agora você conhece a diferença entre Estado Local, Global e Async.

Local -> Apenas Componente. Global -> Single-Json-NoSQL-DB-For-The-FE. Async -> Dados externos, tipo gato de Schrodinger, vivendo fora da aplicação FE que requer Loading e pode retornar Error.

Espero que você tenha gostado deste artigo, deixe-me saber se você tem opiniões diferentes ou qualquer feedback construtivo, abraços!