Como os dados trafegam entre componentes?
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 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".
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.
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ê retornarundefined
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.
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.
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.