Single Source of Truth em componentes React
Neste artigo irei explicar o que é Single Source of Truth e como ele pode ser utilizado para prevenir possíveis bugs e ainda melhorar a legibilidade dos seus componentes.
Single Source of Truth (ou SSOT) é uma prática de estruturar as informações em sua aplicação de forma que haja um ponto central, que é o estado em si, e ramificações desse estado, que são derivações da informação contida no estado. Em termos simples, o SSOT refere-se a ter um único local onde o estado da aplicação é armazenado. Em vez de espalhar o estado por diferentes partes do código, você centraliza todas as informações relevantes em um único ponto. Isso oferece várias vantagens:
O primeiro passo para entender o SSOT é saber o que é e como desenvolver estados centralizados. Para compreender melhor o conceito aplicado no contexto de componentes React, vamos primeiro dar uma olhada no exemplo abaixo:
1const fruits = [];2function FruitList() {3 const [query, setQuery] = useState('');4 const [filteredFruits, setFilteredFruits] = useState(fruits);5
6 const filter = (newQuery: string) => {7 setQuery(newQuery);8 setFilteredFruits(filteredFruits.filter((fruit) => !query || fruit.name.includes(query)));9 };10
11 return (12 <>13 <input14 value={query}15 onChange={(event) => {16 const newValue = event.target.value;17 filter(newValue);18 }}19 />20 {fruits.map((fruit) => (21 <Fruit key={fruit.id} fruit={fruit} />22 ))}23 </>24 );25}
Atenção! Essa não é forma recomendada de implementar esses tipos de funcionalidades, o código utilizado aqui é apenas para fins de demonstração.
O componente FruitList
possui dois estados: query
e filteredFruits
. O primeiro estado guarda o texto que o usuário digitou e o segundo guarda a lista de frutas filtrada pelo texto. A primeira vista o código aparenta não ter problemas, entretanto conforme são implementadas mais regras algumas dificuldades começam a aparecer.
Vamos fazer um ajuste no componente e adicionar um botão para limpar a pesquisa. Uma funcionalidade relativamente simples:
1const fruits = [];2function FruitList() {7 linhas omitidas
3 const [query, setQuery] = useState('');4 const [filteredFruits, setFilteredFruits] = useState(fruits);5
6 const filter = (newQuery: string) => {7 setQuery(newQuery);8 setFilteredFruits(fruits.filter((fruit) => !query || fruit.name.includes(query)));9 };10
11 const clear = () => {12 setQuery('');13 };14
15 return (8 linhas omitidas
16 <>17 <input18 value={query}19 onChange={(event) => {20 const newValue = event.target.value;21 filter(newValue);22 }}23 />24 <button onClick={clear}>limpar</button>4 linhas omitidas
25 {fruits.map((fruit) => (26 <Fruit key={fruit.id} fruit={fruit} />27 ))}28 </>29 );30}
Após implementar a funcionalidade já nos deparamos com o primeiro problema: o campo de texto foi limpo, porém os resultados seguem filtrados. Algumas das soluções possíveis para o problema seriam:
setFilteredFruits
dentro de clear
para reiniciar a lista sem o filtro aplicadofilter
passando um valor vaziosetFilteredFruits
para dentro de um useEffect
Todas essas abordagens seguem o caminho de tentar sincronizar os estados query
e filteredFruits
. Em ambos os casos a solução seria trivial: uma linha de código e o trabalho está feito. Entretanto, conforme novas regras de negócio são adicionadas e a complexidade do projeto cresce, a sincronização de estados passa a se tornar mais difícil e consequentemente começa a ser a causa raiz de muitos bugs.
Vamos analisar outro exemplo do mesmo componente com o código refatorado, restando apenas um estado que é a fonte única da verdade:
1const fruits = [];2function FruitList() {3 const [query, setQuery] = useState('');4 const [filteredFruits, setFilteredFruits] = useState(fruits);5 const filteredFruits = fruits.filter((fruit) => !query || fruit.name.includes(query));6
7 const filter = (newQuery: string) => {8 setQuery(newQuery);9 setFilteredFruits(fruits.filter((fruit) => !query || fruit.name.includes(query)));10 };11
19 linhas omitidas
12 const clear = () => {13 setQuery('');14 };15
16 return (17 <>18 <input19 value={query}20 onChange={(event) => {21 const newValue = event.target.value;22 filter(newValue);23 }}24 />25 <button onClick={clear}>limpar</button>26 {fruits.map((fruit) => (27 <Fruit key={fruit.id} fruit={fruit} />28 ))}29 </>30 );31}
Agora nesse exemplo a variável filteredFruits
, que antes era um estado por si só, tornou-se apenas o resultado de uma expressão baseada no estado query
. Dessa forma, temos a garantia de que o valor de filteredFruits
sempre estará de acordo com o valor de query
, sem necessidade de nenhuma sincronização.
“Mas e a performance?”
Vamos lembrar da famosa frase: “otimização prematura é a raíz de todo mal”.
No exemplo exibido temos uma lista que possui poucos itens dentro de um componente que será renderizado apenas quando o filtro mudar.
Prefira o código simples e legível e apenas otimize se você tem meios de afirmar que aquela otimização se faz necessária. Mesmo em manipulação de arrays o custo de performance as vezes é negligível. Caso seja realmente necessário, o hookuseMemo
pode ser utilizado para que a expressão não impacte em termos de performance.
O conceito SSOT não se limita apenas a um componente, você pode e deve pensar a centralização dos estados através de sua aplicação. Os benefícios são os mesmos, porém é necessário pensar nas abordagens possíveis e qual a ideal para seu caso de uso.
A abordagem mais prática é simplesmente passar o estado via props. O componente pai possui o estado e fornece o valor atual via prop para os componentes filhos. Os componentes filhos, por sua vez, chamam callbacks para comunicar que desejam atualizar o valor do estado e o componente pai processa a solicitação.
1function FruitList() {2 const [query, setQuery] = useState('');3
4 const filter = (newQuery: string) => {5 setQuery(newQuery);6 };7
8 return (9 <>10 <SearchBox query={query} onChange={filter} />11 {/* omitido por brevidade */}12 </>13 );14}15
16function SearchBox({ query, onChange }) {17 // o componente filho não altera o estado diretamente,18 // em vez disso, chama o onChange para indicar a intenção de fazer a alteração19
20 // a propriedade não poderia se chamar "setQuery"?21 // não, a nome "onChange" é proposital para essa intenção no código.22 // "set" indica algo imperativo, "onXXX" indica uma notificação,23 // sem necessariamente uma alteração de estado24 return <input value={query} onChange={({ target }) => onChange(target.value)} />;25}
Essa abordagem também é conhecida como componentes controlados.
Essa forma de passar o estado adiante tem pontos positivos e negativos. Alguns pontos positivos:
Alguns pontos negativos:
Existe outra abordagem que mitiga os pontos negativos de passar o estado através de props, utilizando contexto do React. O contexto pode ser utilizado para transmitir o valor de um estado com toda a árvore de componentes filhos, mantendo os mesmos sincronizados.
1const ThemeContext = createContext<'dark' | 'light'>('light');2function FruitList() {3 const [theme, setTheme] = useState('light');4
5 return (6 <ThemeContext.Provider value={theme}>7 <Header />8 </ThemeContext.Provider>9 );10}11
12function Header() {13 const theme = useContext(ThemeContext);14 return <>o tema atual é {theme}</>;15}
Vamos analisar os pontos positivos dessa abordagem:
Agora alguns pontos negativos:
É muito difícil definir uma regra geral de qual abordagem escolher em cada situação, pois cada problema tem suas especificidades, mas aqui vou deixar algumas recomendações:
Em componentes genéricos e reutilizáveis (componentes de design system, por exemplo), quando o objetivo é trafegar estado interno ou quando o estado abrange muitas partes do sistema, o recomendável é utilizar contexto.
Por exemplo, um componente de TabBar
pode passar o estado da aba ativa para os componentes Tab
filhos através de contexto, sem a necessidade de cada um deles receber essa informação via props.
1// o componente TabBar possui o estado da aba atual2<TabBar>3 <Tab>Aba 1</Tab>4 {/* o componente Tab sabe qual a aba ativa através de contexto */}5 <Tab>Aba 2</Tab>6 <Tab>Aba 3</Tab>7</TabBar>
Em componentes mais especializados ou quando o estado é externo, é recomendado utilizar props e componentes controlados:
1const [query, setQuery] = useState('');2
3// o componente SearchBox recebe o estado diretamente via props4return <SearchBox value={query} onChange={setQuery} />;
Mas como nada é preto no branco, existem situações onde ambas as abordagens possam ser aplicadas:
1const [tab, setTab] = useState(0);2
3// o componente TabBar recebe o estado diretamente via props4return (5 <TabBar current={tab} onChange={setTab}>6 {/* o componente Tab, por sua vez,7 recebe o mesmo estado através do contexto do TabBar */}8 <Tab>Aba 1</Tab>9 <Tab>Aba 2</Tab>10 <Tab>Aba 3</Tab>11 </TabBar>12);
Espero que esse artigo ajude você a escrever componentes mais fáceis de manter e consequentemente te poupe dor de cabeça no futuro. Até a próxima!