Qual dessas estratégias é a melhor maneira de redefinir o estado de um componente quando os objetos são alterados

8

Eu tenho um componente muito simples com um campo de texto e um botão:

<image>

Leva uma lista como entrada e permite ao usuário percorrer a lista.

O componente possui o seguinte código:

import * as React from "react";
import {Button} from "@material-ui/core";

interface Props {
    names: string[]
}
interface State {
    currentNameIndex: number
}

export class NameCarousel extends React.Component<Props, State> {

    constructor(props: Props) {
        super(props);
        this.state = { currentNameIndex: 0}
    }

    render() {
        const name = this.props.names[this.state.currentNameIndex].toUpperCase()
        return (
            <div>
                {name}
                <Button onClick={this.nextName.bind(this)}>Next</Button>
            </div>
        )
    }

    private nextName(): void {
        this.setState( (state, props) => {
            return {
                currentNameIndex: (state.currentNameIndex + 1) % props.names.length
            }
        })
    }
}

Esse componente funciona muito bem, exceto que eu não lidei com o caso quando o estado muda. Quando o estado mudar, eu gostaria de redefinir o valor currentNameIndexpara zero.

Qual é a melhor maneira de fazer isso?


Opções que eu concordei:

Usando componentDidUpdate

Esta solução é desagradável, porque componentDidUpdateé executada após a renderização, portanto, preciso adicionar uma cláusula no método de renderização para "não fazer nada" enquanto o componente estiver em um estado inválido. Se não tomar cuidado, posso causar uma exceção de ponteiro nulo .

Eu incluí uma implementação disso abaixo.

Usando getDerivedStateFromProps

O getDerivedStateFromPropsmétodo é statice a assinatura apenas fornece acesso ao estado atual e aos próximos objetos. Este é um problema porque você não pode dizer se os objetos foram alterados. Como resultado, isso força você a copiar os adereços no estado para poder verificar se são os mesmos.

Tornando o componente "totalmente controlado"

Eu não quero fazer isso. Esse componente deve ser o proprietário privado do índice atualmente selecionado.

Tornando o componente "totalmente descontrolado com uma chave"

Estou considerando essa abordagem, mas não gosto de como ela faz com que os pais precisem entender os detalhes de implementação da criança.

Ligação


Diversos

Passei muito tempo lendo Você provavelmente não precisa de um estado derivado, mas estou bastante descontente com as soluções propostas lá.

Sei que variações desta pergunta foram feitas várias vezes, mas não acho que nenhuma das respostas pesa as possíveis soluções. Alguns exemplos de duplicatas:


Apêndice

Solução usando componetDidUpdate(veja a descrição acima)

import * as React from "react";
import {Button} from "@material-ui/core";

interface Props {
    names: string[]
}
interface State {
    currentNameIndex: number
}

export class NameCarousel extends React.Component<Props, State> {

    constructor(props: Props) {
        super(props);
        this.state = { currentNameIndex: 0}
    }

    render() {

        if(this.state.currentNameIndex >= this.props.names.length){
            return "Cannot render the component - after compoonentDidUpdate runs, everything will be fixed"
        }

        const name = this.props.names[this.state.currentNameIndex].toUpperCase()
        return (
            <div>
                {name}
                <Button onClick={this.nextName.bind(this)}>Next</Button>
            </div>
        )
    }

    private nextName(): void {
        this.setState( (state, props) => {
            return {
                currentNameIndex: (state.currentNameIndex + 1) % props.names.length
            }
        })
    }

    componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): void {
        if(prevProps.names !== this.props.names){
            this.setState({
                currentNameIndex: 0
            })
        }
    }

}

Solução usando getDerivedStateFromProps:

import * as React from "react";
import {Button} from "@material-ui/core";

interface Props {
    names: string[]
}
interface State {
    currentNameIndex: number
    copyOfProps?: Props
}

export class NameCarousel extends React.Component<Props, State> {

    constructor(props: Props) {
        super(props);
        this.state = { currentNameIndex: 0}
    }

    render() {

        const name = this.props.names[this.state.currentNameIndex].toUpperCase()
        return (
            <div>
                {name}
                <Button onClick={this.nextName.bind(this)}>Next</Button>
            </div>
        )
    }


    static getDerivedStateFromProps(props: Props, state: State): Partial<State> {

        if( state.copyOfProps && props.names !== state.copyOfProps.names){
            return {
                currentNameIndex: 0,
                copyOfProps: props
            }
        }

        return {
            copyOfProps: props
        }
    }

    private nextName(): void {
        this.setState( (state, props) => {
            return {
                currentNameIndex: (state.currentNameIndex + 1) % props.names.length
            }
        })
    }


}
sixtyfootersdude
fonte
3
Por que o componente está recebendo a lista inteira de nomes quando o trabalho está exibindo apenas um único nome? levante o estado e apenas passe o nome selecionado como suporte e função para atualizar o currentIndex. Quando namesestão sendo atualizados no pai simplesmente redefinircurrentIndex
Asaf Aviv
1
Gosto da solução @AsafAvivs: passe uma função nextpara o componente. O botão exibe o nome, e seu onClick apenas chama a função seguinte, localizada no pai, que lida com o ciclismo
Dániel Somogyi
1
@ DánielSomogyi - Eu acho que terei um problema semelhante nos pais ...... #
sixtyfootersdude
1
Você pode incluir o componente pai que contém os nomes?
Asaf Aviv
1
@ DánielSomogyi que apenas move o problema para o pai, a menos que o pai seja o componente que faz a busca assíncrona que pode saber quando redefinir o estado.
Emile Bergeron

Respostas:

2

Você pode usar um componente funcional? Pode simplificar um pouco as coisas.

import React, { useState, useEffect } from "react";
import { Button } from "@material-ui/core";

interface Props {
    names: string[];
}

export const NameCarousel: React.FC<Props> = ({ names }) => {
  const [currentNameIndex, setCurrentNameIndex] = useState(0);

  const name = names[currentNameIndex].toUpperCase();

  useEffect(() => {
    setCurrentNameIndex(0);
  }, names);

  const handleButtonClick = () => {
    setCurrentIndex((currentNameIndex + 1) % names.length);
  }

  return (
    <div>
      {name}
      <Button onClick={handleButtonClick}>Next</Button>
    </div>
  )
};

useEffecté semelhante ao local componentDidUpdateonde será usada uma matriz de dependências (variáveis ​​de estado e prop) como o segundo argumento. Quando essas variáveis ​​mudam, a função no primeiro argumento é executada. Simples assim. Você pode fazer verificações lógicas adicionais dentro do corpo da função para definir variáveis ​​(por exemplo, setCurrentNameIndex).

Apenas tome cuidado se você tiver uma dependência no segundo argumento que é alterado dentro da função, então você terá repetições infinitas.

Confira os documentos useEffect , mas provavelmente você nunca mais desejará usar um componente de classe novamente depois de se acostumar com os ganchos.

Tunn
fonte
Esta é uma boa resposta. Parece que funciona devido ao useEffect. Se você atualizar para explicar por que isso funciona, marcarei isso como a resposta aceita.
sixtyfootersdude 13/01
Confira a resposta atualizada. Entre em contato se precisar de mais esclarecimentos.
Tunn 14/01
4

Como eu disse nos comentários, não sou fã dessas soluções.

Os componentes não devem se importar com o que os pais estão fazendo ou com a corrente statedos pais, eles devem simplesmente absorver propse produzir alguns JSX, dessa forma eles são verdadeiramente reutilizáveis, composíveis e isolados, o que também facilita muito o teste.

Podemos fazer com que o NamesCarouselcomponente mantenha os nomes do carrossel, juntamente com a funcionalidade do carrossel e o nome visível atual, e criar um Namecomponente que faça apenas uma coisa, exibindo o nome que apareceprops

Para redefinir selectedIndexquando os itens estão mudando, adicione a useEffectcom itens como uma dependência, embora se você apenas adicionar itens ao final da matriz, poderá ignorar esta parte

const Name = ({ name }) => <span>{name.toUpperCase()}</span>;

const NamesCarousel = ({ names }) => {
  const [selectedIndex, setSelectedIndex] = useState(0);

  useEffect(() => {
    setSelectedIndex(0)
  }, [names])// when names changes reset selectedIndex

  const next = () => {
    setSelectedIndex(prevIndex => prevIndex + 1);
  };

  const prev = () => {
    setSelectedIndex(prevIndex => prevIndex - 1);
  };

  return (
    <div>
      <button onClick={prev} disabled={selectedIndex === 0}>
        Prev
      </button>
      <Name name={names[selectedIndex]} />
      <button onClick={next} disabled={selectedIndex === names.length - 1}>
        Next
      </button>
    </div>
  );
};

Agora isso está bem, mas é NamesCarouselreutilizável? não, o Namecomponente é mas Carouselestá acoplado ao Namecomponente.

Então, o que podemos fazer para torná-lo verdadeiramente reutilizável e ver os benefícios de projetar um componente isoladamente ?

Podemos tirar proveito do padrão de adereços de renderização .

Vamos criar um Carouselcomponente genérico que terá uma lista genérica itemse chamará a childrenfunção que passa no campo selecionado.item

const Carousel = ({ items, children }) => {
  const [selectedIndex, setSelectedIndex] = useState(0);

  useEffect(() => {
    setSelectedIndex(0)
  }, [items])// when items changes reset selectedIndex

  const next = () => {
    setSelectedIndex(prevIndex => prevIndex + 1);
  };

  const prev = () => {
    setSelectedIndex(prevIndex => prevIndex - 1);
  };

  return (
    <div>
      <button onClick={prev} disabled={selectedIndex === 0}>
        Prev
      </button>
      {children(items[selectedIndex])}
      <button onClick={next} disabled={selectedIndex === items.length - 1}>
        Next
      </button>
    </div>
  );
};

Agora, o que esse padrão realmente nos dá?

Isso nos dá a capacidade de renderizar o Carouselcomponente assim

// items can be an array of any shape you like
// and the children of the component will be a function 
// that will return the select item
<Carousel items={["Hi", "There", "Buddy"]}>
  {name => <Name name={name} />} // You can render any component here
</Carousel>

Agora, eles são isolados e realmente reutilizáveis, você pode transmitir itemscomo uma variedade de imagens, vídeos ou até usuários.

Você pode ir além e fornecer ao carrossel o número de itens que deseja exibir como acessórios e chamar a função filho com uma variedade de itens

return (
  <div>
    {children(items.slice(selectedIndex, selectedIndex + props.numOfItems))}
  </div>
)

// And now you will get an array of 2 names when you render the component
<Carousel items={["Hi", "There", "Buddy"]} numOfItems={2}>
  {names => names.map(name => <Name key={name} name={name} />)}
</Carousel>
Asaf Aviv
fonte
Eu não acho que isso será redefinido selectedIndexquando os adereços mudarem, não é? Se sim, como?
sixtyfootersdude 6/01
1
@sixtyfootersdude atualmente não, vou atualizar minha resposta em um segundo.
Asaf Aviv
1

Você pergunta qual é a melhor opção, a melhor opção é torná-lo um componente controlado.

O componente está muito baixo na hierarquia para saber como lidar com as alterações nas propriedades - e se a lista mudasse apenas um pouco (talvez adicionando um novo nome) - o componente de chamada pode querer manter a posição original.

Em todos os casos, posso pensar que estamos em melhor situação se o componente pai puder decidir como o componente deve se comportar quando receber uma nova lista.

Também é provável que esse componente faça parte de um todo maior e precise passar a seleção atual para seu pai - talvez como parte de um formulário.

Se você é realmente inflexível em não torná-lo um componente controlado, existem outras opções:

  • Em vez de um índice, você pode manter o nome inteiro (ou um componente de identificação) no estado - e se esse nome não existir mais na lista de nomes, retorne o primeiro na lista. Esse é um comportamento um pouco diferente dos requisitos originais e pode ser um problema de desempenho para uma lista realmente muito longa, mas é muito limpa.
  • Se você está bem com ganchos, então, useEffectcomo sugeriu Asaf Aviv, é uma maneira muito limpa de fazer isso.
  • A maneira "canônica" de fazer isso com as classes parece ser getDerivedStateFromProps- e sim, isso significa manter uma referência à lista de nomes no estado e compará-la. Pode parecer um pouco melhor se você escrever algo assim:
   static getDerivedStateFromProps(props: Props, state: State = {}): Partial<State> {

       if( state.names !== props.names){
           return {
               currentNameIndex: 0,
               names: props.names
           }
       }

       return null; // you can return null to signify no change.
   }

(você provavelmente também deve usar state.nameso método render se escolher esta rota)

Mas o componente realmente controlado é o caminho a percorrer, provavelmente você o fará mais cedo ou mais tarde, quando as demandas mudarem e os pais precisarem conhecer o item selecionado.

Alon Bar David
fonte
Enquanto eu discordo de:, The component is too low in the hierarchy to know how to handle it's properties changingesta é a melhor resposta. As alternativas que você fornece são interessantes e úteis. A principal razão pela qual não quero expor essa proposta é que esse componente será usado em vários projetos e quero que a API seja mínima.
sixtyfootersdude 13/01
No entanto, uma observação: If you are ok with hooks, than useEffect as Asaf Aviv suggested is a very clean way to do it.- Isso não resolve o problema - apesar de ter 4 votos, essa pergunta não redefine o estado na alteração de adereços.
sixtyfootersdude 13/01
useEffect(() => { setSelectedIndex(0) }, [items]) O segundo argumento do UseEffect define quando deve ser executado - com a mesma eficácia que será executado setSelectedIndex(0)sempre que os itens forem alterados. Qual é a sua exigência, tanto quanto eu o entendo.
Alon Bar David
O @sixtyfootersdude useEffectna minha solução redefinirá o índice quando nameshouver alterações, o Tunn useEffecté inválido e mostrará erros de fiapos e lançará erros quando os nomes estiverem mudando.
Asaf Aviv
Vale ressaltar que, se não for redefinido para você, você está mudando namesdiretamente, o que é outro problema.
Asaf Aviv 14/01