DEV Community

Cover image for Reescrevendo a biblioteca Recoil para React em 100 linhas
Eduardo Rabelo
Eduardo Rabelo

Posted on

Reescrevendo a biblioteca Recoil para React em 100 linhas

Créditos da Imagem

Recoil é uma nova biblioteca do React, escrita por algumas pessoas do Facebook que trabalham em uma ferramenta chamada "Comparison View". Ela surgiu devido a problemas de ergonomia e desempenho com context e useState. É uma biblioteca muito inteligente e quase todo mundo encontrará uma utilidade para ela - confira este vídeo explicativo se quiser saber mais.

No começo eu fiquei realmente surpreso com a conversa sobre teoria dos gráficos e a mágica maravilhosa que o Recoil executa, mas depois de um tempo comecei a ver que talvez não seja tão especial assim. Aqui está minha chance de implementar algo semelhante!

Antes de começar, observe que a maneira como implementei meu clone do Recoil é completamente diferente de como o Recoil real é implementado. Não presuma nada sobre Recoil disso.

Átomos

O Recoil é construído em torno do conceito de “átomos”. Os átomos são pequenas partes atômicas de estado que você pode assinar e atualizar em seus componentes.

Para começar, vou criar uma classe chamada Atom que vai envolver algum valor T. Eu adicionei métodos auxiliares update e snapshotpara permitir que você obtenha e defina o valor.

class Atom<T> {
  constructor(private value: T) {}

  update(value: T) {
    this.value = value;
  }

  snapshot(): T {
    return this.value;
  }
}

Para ouvir as mudanças no estado, você precisa usar o padrão do observador . Isso é comumente visto em bibliotecas como RxJS , mas, neste caso, vou escrever uma versão síncrona simples do zero.

Para saber quem está ouvindo o estado, uso um Set com callbacks. Um Set (ou conjunto de hash) é uma estrutura de dados que contém apenas itens únicos. Em JavaScript, ele pode ser facilmente transformado em um array e possui métodos úteis para adicionar e remover itens rapidamente.

A adição de um ouvinte é feita por meio do método subscribe. O método subscribe retorna Disconnecter - uma interface contendo um método que impedirá um ouvinte de escutar. Isso é chamado quando um componente React é desmontado e você não deseja mais ouvir as alterações.

Em seguida, um método chamado emit é adicionado. Este método percorre cada um dos ouvintes e fornece a eles o valor atual do estado.

Por fim, atualizo o método update para emitir os novos valores sempre que o estado for definido.

type Disconnecter = { disconnect: () => void };

class Atom<T> {
  private listeners = new Set<(value: T) => void>();

  constructor(private value: T) {}

  update(value: T) {
    this.value = value;
    this.emit();
  }

  snapshot(): T {
    return this.value;
  }

  emit() {
    for (const listener of this.listeners) {
      listener(this.snapshot());
    }
  }

  subscribe(callback: (value: T) => void): Disconnecter {
    this.listeners.add(callback);
    return {
      disconnect: () => {
        this.listeners.delete(callback);
      },
    };
  }
}

Ufa!

É hora de escrever o átomo em nossos componentes React. Para fazer isso, criei um gancho chamado useCoiledValue. ( soa familiar? )

Este gancho retorna o estado atual de um átomo, e escuta e renderiza novamente sempre que o valor mudar. Sempre que o gancho é desmontado, ele desconecta o ouvinte.

Uma coisa um pouco estranha aqui é o gancho updateState. Ao executar um estado definido com uma nova referência de objeto ( {}), o React irá renderizar novamente o componente. Isso é um pouco um hack, mas é uma maneira fácil de garantir que o componente seja renderizado novamente.

export function useCoiledValue<T>(value: Atom<T>): T {
  const [, updateState] = useState({});

  useEffect(() => {
    const { disconnect } = value.subscribe(() => updateState({}));
    return () => disconnect();
  }, [value]);

  return value.snapshot();
}

Em seguida, adicionei um método useCoiledState. Tem uma API muito semelhante a useState- fornece o valor atual do estado e permite que você defina um novo.

export function useCoiledState<T>(atom: Atom<T>): [T, (value: T) => void] {
  const value = useCoiledValue(atom);
  return [value, useCallback((value) => atom.update(value), [atom])];
}

Agora que implementamos esses ganchos, é hora de passar para os seletores. Antes disso, vamos refatorar um pouco o que temos.

Um seletor é um valor com estado, assim como um átomo. Para tornar a implementação deles um pouco mais fácil, moverei a maior parte da lógica de Atom para uma classe base chamada Stateful.

class Stateful<T> {
  private listeners = new Set<(value: T) => void>();

  constructor(private value: T) {}

  protected _update(value: T) {
    this.value = value;
    this.emit();
  }

  snapshot(): T {
    return this.value;
  }

  subscribe(callback: (value: T) => void): Disconnecter {
    this.listeners.add(callback);
    return {
      disconnect: () => {
        this.listeners.delete(callback);
      },
    };
  }
}

class Atom<T> extends Stateful<T> {
  update(value: T) {
    super._update(value);
  }
}

Seguindo em frente!

Seletores

Um seletor é a versão de Recoil de “valores computados” ou “redutores”. Em suas próprias palavras :

Um seletor representa uma parte derivado do estado. Você pode pensar no estado derivado como a saída da passagem do estado para uma função pura que modifica o estado fornecido de alguma forma.

A API para seletores no Recoil é bastante simples, você cria um objeto com um método chamado get e tudo o que esse método retorna é o valor do seu estado. Dentro do método get, você pode assinar outras partes do estado e, sempre que elas forem atualizadas, o seu seletor também será.

Em nosso caso, vou renomear o método get a ser chamado de generator. Estou chamando-o assim porque é essencialmente uma função de fábrica que deve gerar o próximo valor do estado, com base em tudo o que é canalizado para ele.

Um fluxograma demonstrando dois átomos (intitulados “hello” e “bob”) sendo canalizados para um seletor, com a saída se tornando “Hello, Bob”

No código, podemos capturar esse método generate com a seguinte assinatura de tipo.

type SelectorGenerator<T> = (context: GeneratorContext) => T;

Para aqueles que não estão familiarizados com TypeScript, essa é uma função que recebe um objeto de contexto ( GeneratorContext) como parâmetro e retorna algum valor T. Esse valor de retorno é o que se torna o estado interno do seletor.

O que o objeto GeneratorContext faz?

Bem, é assim que os seletores usam outras partes do estado ao gerar seu próprio estado interno. De agora em diante, vou me referir a essas partes do estado como “dependências”.

interface GeneratorContext {
  get: <V>(dependency: Stateful<V>) => V
}

Sempre que alguém chama o método get no GeneratorContext, ele adiciona um pedaço de estado como uma dependência. Isso significa que sempre que uma dependência for atualizada, o seletor também será.

Veja como fica a criação da função de geração de um seletor:

function generate(context) {
  // Registra "NameAtom" como dependência
  // e retorna seu valor
  const name = context.get(NameAtom);
  // Faz o mesmo para "AgeAtom"
  const age = context.get(AgeAtom);

  // Retorna um novo valor usando os átomos anteriores
  // Ex: "Bob is 20 years old"
  return `${name} is ${age} years old.`;
};

Com a função de geração de estado fora do caminho, vamos criar a classe Selector. Essa classe deve aceitar a função de geração como um parâmetro do construtor e usar um método getDep na classe para retornar o valor das Atom de dependências.

Você pode notar no construtor que escrevi super(undefined as any). Isso ocorre porque super deve ser a primeira linha no construtor de uma classe derivada. Se ajudar, neste caso você pode pensar em undefined como uma memória não inicializada.

export class Selector<T> extends Stateful<T> {
  private getDep<V>(dep: Stateful<V>): V {
    return dep.snapshot();
  }

  constructor(
    private readonly generate: SelectorGenerator<T>
  ) {
    super(undefined as any);
    const context = {
      get: dep => this.getDep(dep) 
    };
    this.value = generate(context);
  }
}

Este seletor só é bom para gerar estado uma vez. Para reagir às mudanças nas dependências, precisamos assiná-las.

Para fazer isso, vamos atualizar o método getDep para assinar as dependências e chamar o método updateSelector. Para garantir que o seletor seja atualizado apenas uma vez a cada alteração, vamos acompanhar as dependências usando um Set.

O método updateSelector é muito semelhante ao construtor do exemplo anterior. Ele cria GeneratorContext, executa o método generate e então usa o método update da classe base Stateful.

export class Selector<T> extends Stateful<T> {
  private registeredDeps = new Set<Stateful>();

  private getDep<V>(dep: Stateful<V>): V {
    if (!this.registeredDeps.has(dep)) {
      dep.subscribe(() => this.updateSelector());
      this.registeredDeps.add(dep);
    }

    return dep.snapshot();
  }

  private updateSelector() {
    const context = {
      get: dep => this.getDep(dep)
    };
    this.update(this.generate(context));
  }

  constructor(
    private readonly generate: SelectorGenerator<T>
  ) {
    super(undefined as any);
    const context = {
      get: dep => this.getDep(dep) 
    };
    this.value = generate(context);
  }
}

Quase pronto! Recoil tem algumas funções auxiliares para criar átomos e seletores. Como a maioria dos desenvolvedores de JavaScript considera as classes como má prática, eles ajudarão a mascarar nossas atrocidades.

Um para criar um átomo ...

export function atom<V>(
  value: { key: string; default: V }
): Atom<V> {
  return new Atom(value.default);
}

E um para criar um seletor ...

export function selector<V>(value: {
  key: string;
  get: SelectorGenerator<V>;
}): Selector<V> {
  return new Selector(value.get);
}

Oh, lembra daquele gancho useCoiledValue de antes? Vamos atualizar isso para aceitar seletores também:

export function useCoiledValue<T>(value: Stateful<T>): T {
  const [, updateState] = useState({});

  useEffect(() => {
    const { disconnect } = value.subscribe(() => updateState({}));
    return () => disconnect();
  }, [value]);

  return value.snapshot();
}

É isso aí! Conseguimos! 🎉

Dê um tapinha nas suas costas!

Acabado?

Por uma questão de brevidade (e para usar aquele título de “100 linhas” para ganhar uns cliques), decidi omitir comentários, testes e exemplos. Se você quiser uma explicação mais completa (ou quiser brincar com exemplos), tudo isso está no meu repositório “recoil-clone” do Github.

Também há um exemplo de site ao vivo para que você possa testá-lo.

Conclusão

Uma vez li que todo bom software deve ser simples o suficiente para que qualquer pessoa possa reescrevê-lo, se necessário. O Recoil tem muitos recursos que não implementei aqui, mas é empolgante ver um design tão simples e intuitivo que pode ser razoavelmente implementado manualmente.

Antes de decidir lançar meu bootleg Recoil em produção, certifique-se de verificar o seguinte:

  • Os seletores nunca cancelam a inscrição dos átomos. Isso significa que eles vazarão memória quando você parar de usá-los.
  • React introduziu um gancho chamado useMutableSource. Se você estiver usando uma versão recente do React, você deve usá-lo ao invés de setState em useCoiledValue.
  • Seletores e Átomos fazem apenas uma comparação superficial entre estados antes de renderizar novamente. Em alguns casos, pode fazer sentido alterar isso para uma comparação profunda.
  • O Recoil usa um campo key para cada átomo e seletor que é usado como metadados para um recurso chamado “observação em todo o aplicativo”. Eu o incluí apesar de não usá-lo para manter a API familiar.
  • O Recoil oferece suporte a seletores assíncronos, isso seria uma tarefa gigantesca, então fiz questão de excluí-lo.

Além disso, espero ter mostrado a você que nem sempre você precisa olhar para uma biblioteca ao decidir sobre as soluções de gerenciamento de estado. Na maioria das vezes, você pode projetar algo que se encaixa perfeitamente na sua solução - afinal, foi assim que o Recoil nasceu .


Depois de escrever este post, vi a biblioteca jotai . É um conjunto de recursos muito semelhante ao meu clone e oferece suporte assíncrono!

Créditos

Top comments (0)