04 / 2021

Como os dados trafegam entre componentes?

Tag: React

A divisão da interface de usuário em componentes implica a necessidade de tráfego de informação entre estes. Existem 2 principais formas de tráfego de dados.

Props

Props dataflow

Props são portas de entrada (e saída) de dados de um componente.

O fluxo ocorre entre um componente e seu pai direto. Para que um componente acesse estado presente em um pai indireto (o pai do pai) via props, o dado tem que trafegar pelo componente intermediário. É como se fosse uma autoestrada passando no meio de uma cidade.

Abaixo exemplos em código representando a imagem acima:

React:

function ComponentWithState() {
  const [productInfo, setProductInfo] = useState('Product')
  return <Intermediary 
    productInfo={productInfo}
    productInfoChange={ev => setProductInfo(ev.target.value)}
  />
}

function Intermediary({ productInfo, productInfoChange }) {
  return <ChildDesiresData
    productInfo={productInfo}
    productInfoChange={productInfoChange}
  />
}

function ChildDesiresData({ productInfo, productInfoChange}) {
  return <input
    type="text"
    value={productInfo}
    onChange={productInfoChange}
  />
}

Injeção de dependências / Contexto

PS: O "contexto" é uma implementação do pattern de "Injeção de Dependências". Quando usado o termo "Injeção de Dependências", estamos nos referindo a "Contexto".

DI Dataflow

A comunicação entre o dono do estado e o consumidor é realizada por intermédio de um "portal de dados" (termo livre). Com isso, o dado não precisa trafegar em componentes intermediários.

  • O filho, consumidor, se registra para receber dados do "Portal";
  • O detentor do estado se registra para fornecer dados ao "Portal";

No React este "portal" é representado pelo tipo Context. O portal de entrada é o context.Provider, o portal de saída é o hook useContext() (ou o componente context.Consumer).

const thePortal = createContext(null)

function ComponentWithState() {
  const [productInfo, setProductInfo] = useState('Product')
  const payload = {
    productInfo,
    productInfoChange: ev => setProductInfo(ev.target.value)
  }
  // entrada -->
  return <thePortal.Provider value={payload}>
    <Intermediary />
  </thePortal>;
}

function Intermediary() {
  return <div>
    <p>I am intermediary.</p>
    <ChildDesiresData/>
  </div>
}

function ChildDesiresData() {
  // saída <--
  const { productInfo, productInfoChange } = useContext(thePortal)
  return <input
    type="text"
    value={productInfo}
    onChange={productInfoChange}
  />
}

Quando usar props ou contextos?

O caso de uso comum para props são componentes reutilizáveis. Componentes que possuirão múltiplas instâncias no documento.

  • Componentes do sistema de design. Ex: Botão, Bloco, Select, Tabela...
  • Componentes que serão repetidos em um loop. Ex: Card de Pessoa, Linha de Tabela;

Se o componente não é reutilizado, é interessante acessar os dados via contexto.

  • Digamos que temos um grande formulário de CRUD, que se colocado todo em um único componente, daria um arquivo com 3000 linhas;
  • De modo a separar as responsabilidades e organizar o desenvolvimento, esta grande formulário é dividido em muitos componentes menores, com poucas linhas, em múltiplos níveis de aninhamento;
  • O componente pai guarda o estado do CRUD e controla suas modificações;
  • O pai expõe o estado do CRUD (e funções para modificá-lo) através de um contexto. Os filhos acessam e modificam os dados a partir do contexto;

Um componente pode simultaneamente requisitar dados de diferentes "portais" de injeção de dependência. Quando você chama um useTheme() do Material-UI ou um useRouter() do react-router, você está acessando dados provenientes de injeção de dependência.

DI samples

Onde mora o estado de uma aplicação

O estado é atrelado a componentes. Posiciona-se o estado em um componente pai ou filho dependendo da visibilidade desejada.

  • Uma peça de estado é geralmente visível (*) aos componentes filho, privada aos componentes pais.

Na maioria dos casos recomenda-se que se "mova estado para cima". Mas em determinados exceções você quer que ele fique "embaixo".

  • Posiciona se o estado no componente pai na maioria dos casos, para que a toda a árvore de componentes facilmente tenha acesso a esse estado;
  • Posiciona-se o estado no componente filho quando não interessa ao componente pai saber de sua existência. É tipo como se fosse uma propriedade private.

No exemplo abaixo, escrevemos um componente "Autocomplete". O valor selecionado no autocomplete é um estado que mora no componente Pai. A lista de itens exibidos no Autocomplete é definida como informação privada, e assim ela fica como estado do componente filho.

function Host() {
  const [value] = useState(2)
  // ...
  return <Autocomplete 
    value={value}
    onChange={handleChange}
    queryOptions={...}
  />
}

function Autocomplete(
  props: { value, onChange, queryOptions: (...) => Promise<Option[]> }
) {
  const [inputText, setInputText] = useState('')
  const [currentOptions, setCurrentOptions] = useState([] as Option[])
  // controla internamente a lista de opções de acordo com os eventos
  // ...
  return <div>
    <InputText value={inputText} onChange={handleTextChange}/>
    <PopperList list={currentOptions}/>
  </div>
}

No exemplo acima

  • Não interessa ao pai de um componente de Autocomplete saber do conteúdo que o usuário está digitando na caixa de texto (inputText, currentOptions). Interessa a ele apenas o id da opção selecionada;
  • Desta forma valor da caixa de texto é armazenado como estado no autocomplete, tornando-se assim oculto ao componente pai;
  • O ID do item selecionado no autocomplete mora no componente pai;

Biblitecas para gestão de estado

O React não é muito "ergonômico" ao realizarmos a tarefa de propagar informações via contexto. Isto dá origem ao assustador termo "gestão de estado" e à proliferação de bibliotecas existentes para tentar "tapar o buraco" deixado pelo React.

Quando você se faz a pergunta "qual lib de gestão de estado usarei no meu projeto", surgem opções como:

  • Nenhuma. Usar Prop drilling, contextos, memo...
  • Redux
  • Outras: Zustand, Jotai, MobX, use-context-selector, ...

Seguimos este artigo abordando o Redux em mais detalhe.

Redux para dados contextuais

O Redux tem a função de armazenar e trafegar dados contextuais (ao invés do Context). No Redux moderno, utilizamos a biblioteca @reduxjs/tookit, quer trás alguns padrões e facilidades.

O que é, como funciona?

A classe abaixo é um container de estado. Ela possui dados e funções (métodos) para a sua alteração;

class StateContainer {
  // estado
  readonly addresses: Address[] = []
  // função
  addAddress(address: Address) { }
}

const instance = new StateContainer()
  • O Redux também é um container de estado como a classe acima; No exemplo abaixo temos um container redux com propriedades similares;
const slice = createSlice({
  name: 'main',
  initialState: {
    // estado
    adresses: [] as Address[]
  },
  reducers: {
    // função
    addAddress(state, payload: Address) {
      state.addresses.push(payload) // immer
    },
  },
});

const store = configureStore({
  reducer: slice.reducer,
});
  • O isolamento do estado e de sua manipulação fora dos componentes auxilia na organização de código e escrita de testes;

  • As funções do container do Redux (addAddress) são invocadas via passagem de mensagens;

// plain class - direct call
instance.addAddress(address)
// redux store - message passing
const action = slice.actions.addAddress(address) // { type: 'addAddress', payload: '...' }
store.dispatch(action);
  • A característica passagem de mensagens permite a adição de middlewares a chamadas de função, ("chain of responsability");
  • Funções do redux (reducers) não podem fazer mutações no estado anterior. Retorna-se um novo objeto imutavelmente criado a partir do estado anterior; Isso segue a necessidade do React de termos alterações de estado imutáveis (dentre outras razões);
  • O redux-toolkit embute a biblioteca immer em suas APIs de reducer. O immer "cria o próximo estado imutável a partir da mutação do atual". Se você retornar undefined em um reducer, o tookit entenderá que você quer usar o immer. Neste caso, você pode fazer mutações à vontade, apenas não retorne nada no reducer.

react-redux

É a biblioteca que integra o Redux ao React (duh);

Principais APIs:

  • <Provider store={store}>

Passa a store redux no "portal de entrada" do react-redux. Usado na raiz da aplicação. As demais APIs do react-redux exigem e consomem desse portal.

  • useSelector(selector)

Lê algo da store e passa para o componente. O parâmetro passado para a função é chamado de seletor.

Abaixo um caso de uso correto, e um errado:

// exemplo correto
function Component() {
  const person = useSelector(storeState => storeState.card?.person)
  return <Person person={person} />
}

// uso errado
function Component() {
  const person = useSelector(storeState => storeState).card?.person
  return <Person person={person} />
}

O que muda do exemplo correto pro exemplo errado? Embora em ambos os casos os componentes recebam os dados desejados, no segundo caso o componente fará re-render para qualquer alteração da store. No primeiro caso, apenas quando o dado relevante for alterado.

A sacada aqui então é que o useSelector() permite melhorar a performance da aplicação reduzindo renders desnecessários.

Note que se meramente usássemos a API Context para trazer dados, como foi feito no exemplo lá em cima, teríamos um problema similar ao do "uso errado": Todos os consumidores do contexto dariam re-render para qualquer alteração do valor:

// não ideal também!
function ChildDesiresData() {
  const { productInfo, productInfoChange } = useContext(thePortal)
  return <input
    type="text"
    value={productInfo}
    onChange={productInfoChange}
  />
}

O uso de Context sozinho não é performático, teríamos que implementar um mecanismo de seletores para torná-lo mais eficiente. O react-redux já trás isso.

  • useDispatch()

As funções do nosso container de estado são chamadas pelo useDispatch.

function Component() {
  const dispatch = useDispatch()
  return <button onClick={() => dispatch(incrementAction())}>
}

reselect

O reselect é utilizado para trabalharmos com "dados derivados". É uma biblioteca que faz composição de seletores, memoizando seus resultados.

import { createSelector, useSelector } from '@reduxjs/toolkit'

const selectPerson = state => state.person;

function calculateHash(person) {
  // some complex calc...
}

const selectPersonHash = createSelector(
  [selectPerson],
  person => calculateHash(person)
)

function Component() {
  const personHash = useSelector(selectPersonHash)
}

No exemplo acima a função calculateHash é computacionalmente intensiva.

Quando Component renderiza, o selectPersonHash retorna uma versão memoizada do hash. O hash só é recalculado quando person muda.

Infelizmente não dá pra usar seletores memoizados pra retornar Promises, pois quando a Promise finaliza isto não ativará num novo render.

Estado global

O Redux quer que você armazene o estado em uma única store global. Você até pode criar múltiplas stores e amarrá-las a componentes mas isto não é recomendado pelos autores da lib.

State Design

Embora você tenha liberade para desenhar o seu estado como quiser, o Redux sugere que você o divida via slices. Na imagem acima temos um exemplo de uma estrutura de projeto e de seu estado global correspondente.

Embora as páginas (Person, Company...) só possam existir 1 por vez, na estrutura do Redux sugerida cada uma delas possui um slot no objeto. Devemos prestar atenção para que o Redux limpe o estado das páginas não abertas, caso contrário teremos bugs;

Desejável:

{
  "personPage": { ... },
  "companyPage": null,
  "invoicePage": null,
  "productPage": null,
}

Pode trazer problemas:

{
  "personPage": { ... },
  "companyPage": { ... },
  "invoicePage": { ... },
  "productPage": null,
}

Uma forma de atingirmos isso é pelo hook useEffect(). Solicite a limpeza do slice relacionado quando o componente for desmontado.

function PersonPage() {
  const dispatch = useDispatch()
  const person = useSelector(state => state.personPage)
  useEffect(() => {
    dispatch(initPersonPage())
    return () => {
      dispatch(unmountPersonPage())
    }
  }, [])

  if (!person) return <Loading/>
  return <Something person={person}/>
}

Construindo o estado

Existem infinitas maneiras de construirmos e manipularmos o estado no redux, e isto é um problema. Para que a comunidade siga um padrão e para que o desenvolvedor tenha um norte, o @reduxjs/toolkit expõe práticas recomendadas na forma de APIs.

ah sht

Aqui vai um bloco de código grande. Declaramos todo o esqueleto base de uma aplicação. Leia os comentários!

import { configureStore, createSlice } from "@reduxjs/toolkit"
import { Provider, useDispatch, useSelector } from "react-redux"
import { useEffect } from "react"
import { BrowserRouter, Switch, Route } from 'react-router-dom'

/**
 * -- Person slice
 */

interface PersonPageState {}

/**
 * Criamos aqui um bloco de estado para a página "person".
 * Esta definição é encapsulada, não definimos ainda ONDE 
 * este estado vai morar. 
 */
const personPageSlice = createSlice({
  /**
   * este "nome" determina um prefixo a ser adicionado às
   * mensagens das ações.
   * Por ex: o reducer "init" vai gerar uma mensagem com nome 
   * "personPage/init"
   */
  name: "personPage",
  /**
   * deixamos claro que o estado inicial pode ser TAMBÉM nulo, 
   * pois a página pode não estar aberta, ou não estar
   * inicializada.
   * Mas não APENAS nulo. É necessário um cast para que o 
   * typescript entenda todas as possibilidades que esse estado
   * abriga.
   */
  initialState: null as null | PersonPageState,
  reducers: {
    init: (state) => {
      // do something...
      return {}
    },
    unmount: (state) => null,
  },
})

/**
 * -- Product slice
 */

interface ProductPageState {}

const productPageSlice = createSlice({
  name: "productPage",
  initialState: null as null | ProductPageState,
  reducers: {
    init: (state) => {
      // do something...
      return {}
    },
    unmount: (state) => null,
  },
})

/**
 * -- Building the store
 */

const store = configureStore({
  /**
   * aqui definimos onde cada "slice" declarado acima vai morar no
   * estado global
   */
  reducer: {
    personPage: personPageSlice.reducer,
    productPage: productPageSlice.reducer,
  },
  devTools: true,
})

/**
 * -- Wire up redux and TS.
 */

/** 
 * O TS inicialmente não sabe qual é o tipo da sua store. Abaixo segue
 * uma forma recomendada de informá-lo, presente na documentação do redux-toolkit.
 */

type RootState = ReturnType<typeof store.getState>
type AppDispatch = typeof store.dispatch
const useAppDispatch = () => useDispatch<AppDispatch>()

declare module "react-redux" {
  // allow `useSelector` to recognize our app state
  interface DefaultRootState extends RootState {}
}

/**
 * --  Wire up react and redux
 */

function AppRoot() {
  return (
    <BrowserRouter>
      <Provider store={store}>
        <Switch>
          <Route path="/person" component={PersonPage}></Route>
          <Route path="/product" component={ProductPage}></Route>
        </Switch>
      </Provider>
    </BrowserRouter>
  )
}

/**
 * -- Our☭ consumer component
 */

function PersonPage() {
  const dispatch = useAppDispatch()
  const person = useSelector((state) => state.personPage)
  useEffect(() => {
    dispatch(initPersonPage())
    return () => {
      dispatch(personPageSlice.actions.unmount())
    }
  }, [])

  if (!person) return <Loading />
  return <Something person={person} />
}

Conforme comentamos antes, cada página da aplicação tem o seu estado isolado em um createSlice. Estes estados são então combinados na definição da store redux, configureStore. Estes estados podem ser nulos, pois eles correspondem a instâncias de páginas que podem não existir no momento!

Algumas práticas também são recomendadas para que o typescript possa entender melhor o seu estado e assim realizar melhores validações.

Operações assíncronas

TODO: Incrementar issso

As funções de atualização de estado (reducers) presentes no redux são todas síncronas. Existem inúmeras opiniões de como tratar operações assíncronas no redux (por exemplo: thunks ou sagas). O redux-toolkit sugere o uso do createAsyncThunk. Esta escolha não foi tomada levianamente, então vamos seguí-la!

Uma store redux, por padrão, apenas aceita mensagens na forma de um objeto { type: string, payload: any }. O redux-tookit adiciona a opção de passarmos um thunk, que é uma espécie de função iteradora como a abaixo:

const aThunk = async (dispatch, getState) => {
  const data = await readSomething()
  dispatch(syncAction({ data }))
}

Porém, como existem mil formas de tratar erros, o simples uso de um thunk acaba sendo uma opção muito "solta", muito baixo nível. Desta forma, é recomendado o uso do createAsyncThunk, o qual:

  • Isola a regra de negócio das regras de tratamento de Promise;
  • Deixa explícito que temos que tratar as alterações de estado da Promise ('idle' | 'pending' | 'succeeded' | 'failed');

Replicarei aqui parte da documentação do createAsyncThunk. O uso básico dele é assim:

const fetchUserById = createAsyncThunk(
  'users/fetchById',
  // if you type your function argument here
  async (userId: number) => {
    const response = await fetch(`https://reqres.in/api/users/${userId}`)
    return (await response.json()) as Returned
  }
)

interface UsersState {
  entities: []
  loading: 'idle' | 'pending' | 'succeeded' | 'failed'
}

const initialState = {
  entities: [],
  loading: 'idle',
} as UsersState

const usersSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    // fill in primary logic here
  },
  extraReducers: (builder) => {
    builder.addCase(fetchUserById.pending, (state, action) => {
      // both `state` and `action` are now correctly typed
      // based on the slice state and the `pending` action creator
    })
  },
})

No asyncThunk apenas tratamos de regra de negócio. No extraReducers pegamos o dado de resposta (ou o erro) e determinamos onde que ele vai ficar no estado.

Alternativas

Existem bibliotecas alternativas ao Redux para resolver o mesmo problema que ele resolve. Podemos pesquisar por "state management" no google. Qual era o problema que o Redux resolvia mesmo?

  • Armazenamento e propagação eficiente do Context;
  • Isolamento de componente e estado;

Faço aqui menção honrosa ao zustand, que é uma biblioteca que trabalha com conceitos similares ao do Redux, mas com uma API simplificada.

A API do zustand é a mínima possível, sendo muito rápida a leitura da documentação. O zustand não possui utilitários como o combineReducers, por outro lado incentiva o uso de múltiplas stores.