Single Source of Truth em componentes React

Escrito em 5 de mai. de 2024

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:

  • Consistência: Quando há apenas um local para atualizar o estado, você evita inconsistências e erros. Todos os componentes que dependem desse estado sempre verão a versão mais recente.
  • Facilidade de Manutenção: Com um SSOT, a manutenção se torna mais simples. Você não precisa rastrear várias fontes de dados ou se preocupar com conflitos entre diferentes estados.
  • Previsibilidade: O SSOT torna o fluxo de dados mais previsível. Você sabe exatamente de onde vêm os dados e como eles são atualizados.

Eliminando estados desnecessários nos componentes

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:

1
const fruits = [];
2
function 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
<input
14
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:

1
const fruits = [];
2
function 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
<input
18
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:

  • Adicionar outro setFilteredFruits dentro de clear para reiniciar a lista sem o filtro aplicado
  • Reutilizar a função filter passando um valor vazio
  • Mover o setFilteredFruits 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:

1
const fruits = [];
2
function 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
<input
19
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 hook useMemo pode ser utilizado para que a expressão não impacte em termos de performance.

Aplicando SSOT entre dois ou mais componentes

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.

Primeira abordagem: Estado via props

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.

1
function 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
16
function 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ção
19
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 estado
24
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:

  • É simples e prático, utiliza props simples do React.
  • O fluxo dos dados no código fica mais claro.
  • O componente é facilmente reutilizável, já que as props podem ser substituídas a vontade.

Alguns pontos negativos:

  • O código começa a ficar repetitivo quando o estado precisa ser passado para mais componentes, principalmente componentes dentro de outros componentes, o famoso prop drilling.
  • O valor pode fazer um longo caminho dentro dos componentes até chegar onde precisa.

Segunda abordagem: estado via contexto

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.

1
const ThemeContext = createContext<'dark' | 'light'>('light');
2
function FruitList() {
3
const [theme, setTheme] = useState('light');
4
5
return (
6
<ThemeContext.Provider value={theme}>
7
<Header />
8
</ThemeContext.Provider>
9
);
10
}
11
12
function Header() {
13
const theme = useContext(ThemeContext);
14
return <>o tema atual é {theme}</>;
15
}

Vamos analisar os pontos positivos dessa abordagem:

  • Não é necessário adicionar props extras aos componentes.
  • O componente que lê o estado pode estar em qualquer lugar dentro da árvore de componentes filhos, resolvendo o problema do prop drilling.
  • O código fica relativamente mais limpo sem as props em cada componente.

Agora alguns pontos negativos:

  • O fluxo dos dados pode não ficar tão claro no código.
  • O componente só é reutilizável enquanto o contexto está disponível.

Qual abordagem escolher?

É 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 atual
2
<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:

1
const [query, setQuery] = useState('');
2
3
// o componente SearchBox recebe o estado diretamente via props
4
return <SearchBox value={query} onChange={setQuery} />;

Mas como nada é preto no branco, existem situações onde ambas as abordagens possam ser aplicadas:

1
const [tab, setTab] = useState(0);
2
3
// o componente TabBar recebe o estado diretamente via props
4
return (
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!