¿Cuál de estas estrategias es la mejor manera de restablecer el estado de un componente cuando cambian los accesorios?

8

Tengo un componente muy simple con un campo de texto y un botón:

<imagen>

Toma una lista como entrada y permite al usuario recorrer la lista.

El componente tiene el siguiente 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
            }
        })
    }
}

Este componente funciona muy bien, excepto que no he manejado el caso cuando el estado cambia. Cuando el estado cambia, me gustaría restablecer currentNameIndexa cero.

¿Cuál es la mejor manera de hacer esto?


Opciones que he considerado:

Utilizando componentDidUpdate

Esta solución es incómoda, porque se componentDidUpdateejecuta después del renderizado, por lo que necesito agregar una cláusula en el método de renderizado para "no hacer nada" mientras el componente está en un estado no válido, si no tengo cuidado, puedo causar una excepción de puntero nulo .

He incluido una implementación de esto a continuación.

Utilizando getDerivedStateFromProps

El getDerivedStateFromPropsmétodo es staticy la firma solo le da acceso al estado actual y a los próximos accesorios. Esto es un problema porque no se puede saber si los accesorios han cambiado. Como resultado, esto te obliga a copiar los accesorios en el estado para que puedas verificar si son iguales.

Hacer que el componente esté "totalmente controlado"

No quiero hacer esto Este componente debe ser el propietario privado del índice seleccionado actualmente.

Hacer que el componente "esté totalmente descontrolado con una llave"

Estoy considerando este enfoque, pero no me gusta cómo hace que el padre necesite comprender los detalles de implementación del niño.

Enlace


Misceláneos

He pasado mucho tiempo leyendo Probablemente no necesites el estado derivado, pero no estoy contento con las soluciones propuestas allí.

Sé que las variaciones de esta pregunta se han hecho varias veces, pero no creo que ninguna de las respuestas evalúe las posibles soluciones. Algunos ejemplos de duplicados:


Apéndice

Solución usando componetDidUpdate(ver descripción arriba)

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
            })
        }
    }

}

Solución 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
fuente
3
¿Por qué el componente obtiene la lista completa de nombres cuando su trabajo solo muestra un nombre? levante el estado y simplemente pase el nombre seleccionado como accesorio y función para actualizar el currentIndex. Cuando namesse actualizan en el padre, simplemente reiniciecurrentIndex
Asaf Aviv
1
Me gusta la solución @AsafAvivs: pasar una función nextal componente. El botón muestra el nombre y onClick solo llama a la siguiente función, ubicada en el padre, que maneja el ciclismo
Dániel Somogyi
1
@ DánielSomogyi - Creo que tendré un problema similar en los padres ......
sixtyfootersdude
1
¿Puede incluir el componente principal que contiene los nombres?
Asaf Aviv
1
@ DánielSomogyi que simplemente mueve el problema al padre, a menos que el padre sea el componente que realiza la recuperación asíncrona que podría saber cuándo restablecer el estado.
Emile Bergeron

Respuestas:

2

¿Se puede usar un componente funcional? Podría simplificar un poco las cosas.

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>
  )
};

useEffectes similar a componentDidUpdatedonde tomará una matriz de dependencias (variables de estado y prop) como segundo argumento. Cuando esas variables cambian, se ejecuta la función en el primer argumento. Simple como eso. Puede hacer verificaciones lógicas adicionales dentro del cuerpo de la función para establecer variables (por ejemplo, setCurrentNameIndex).

Solo tenga cuidado si tiene una dependencia en el segundo argumento que se cambia dentro de la función, entonces tendrá infinitos rerenders.

Echa un vistazo a los documentos useEffect , pero probablemente nunca quieras volver a usar un componente de clase después de acostumbrarte a los ganchos.

Tunn
fuente
Esta es una buena respuesta. Parece que funciona debido a la useEffect. Si actualiza para explicar por qué esto funciona, lo marcaré como la respuesta aceptada.
sixtyfootersdude
Consulte la respuesta actualizada, avíseme si necesita más aclaraciones.
Tunn
4

Como dije en los comentarios, no soy fanático de estas soluciones.

Los componentes no deben preocuparse por lo que está haciendo el padre o cuál es la corriente statedel padre, simplemente deben aceptar propsy generar algunos JSX, de esta manera son realmente reutilizables, compostables y aislados, lo que también hace que las pruebas sean mucho más fáciles.

Podemos hacer que el NamesCarouselcomponente contenga los nombres del carrusel junto con la funcionalidad del carrusel y el nombre visible actual y hacer que un Namecomponente que solo haga una cosa, muestre el nombre que entraprops

Para restablecer selectedIndexcuando los elementos cambian, agregue un useEffectelemento con como una dependencia, aunque si solo agrega elementos al final de la matriz, puede 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>
  );
};

Ahora bien, ¿pero es NamesCarouselreutilizable? no, el Namecomponente es pero Carouselestá acoplado con el Namecomponente.

Entonces, ¿qué podemos hacer para que sea realmente reutilizable y ver los beneficios de diseñar componentes de forma aislada ?

Podemos aprovechar el patrón de accesorios de renderizado .

Vamos a hacer un Carouselcomponente genérico que tomará una lista genérica itemse invocará la childrenfunción que pasa en el seleccionadoitem

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>
  );
};

¿Qué nos da este patrón en realidad?

Nos da la capacidad de renderizar el Carouselcomponente de esta manera

// 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>

Ahora que están aislados y son realmente reutilizables, puede pasarlos itemscomo un conjunto de imágenes, videos o incluso usuarios.

Puede llevarlo más lejos y darle al carrusel la cantidad de elementos que desea mostrar como accesorios e invocar la función secundaria con una variedad de elementos.

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
fuente
No creo que esto se restablezca selectedIndexcuando cambien los accesorios, ¿verdad? ¿Si es así, cómo?
sixtyfootersdude
1
@sixtyfootersdude actualmente no, actualizaré mi respuesta en un segundo.
Asaf Aviv
1

Pregunta cuál es la mejor opción, la mejor opción es convertirla en un componente controlado.

El componente es demasiado bajo en la jerarquía para saber cómo manejar el cambio de sus propiedades, y si la lista cambiara pero solo un poco (quizás agregando un nuevo nombre), el componente que realiza la llamada podría querer mantener la posición original.

En todos los casos, puedo pensar que estamos mejor si el componente principal puede decidir cómo debe comportarse el componente cuando se le proporciona una nueva lista.

También es probable que dicho componente sea parte de un todo más grande y necesite pasar la selección actual a su padre, tal vez como parte de un formulario.

Si realmente es inflexible en no convertirlo en un componente controlado, hay otras opciones:

  • En lugar de un índice, puede mantener el nombre completo (o un componente de identificación) en el estado, y si ese nombre ya no existe en la lista de nombres, devuelva el primero de la lista. Este es un comportamiento ligeramente diferente de sus requisitos originales y podría ser un problema de rendimiento para una lista realmente muy larga, pero está muy limpio.
  • Si está de acuerdo con los ganchos, entonces, useEffectcomo Asaf Aviv sugirió, es una forma muy limpia de hacerlo.
  • La forma "canónica" de hacerlo con las clases parece ser getDerivedStateFromProps, y sí, eso significa mantener una referencia a la lista de nombres en el estado y compararla. Puede verse un poco mejor si lo escribe de esta manera:
   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.
   }

(probablemente también deberías usar state.namesel método de renderizado si eliges esta ruta)

Pero el componente realmente controlado es el camino a seguir, probablemente lo hará tarde o temprano de todos modos cuando las demandas cambien y el padre necesite saber el elemento seleccionado.

Alon Bar David
fuente
Si bien no estoy de acuerdo con:, The component is too low in the hierarchy to know how to handle it's properties changingesta es la mejor respuesta. Las alternativas que proporciona son interesantes y útiles. La razón principal por la que no quiero exponer este accesorio es que este componente se usará en múltiples proyectos y quiero que la API sea mínima.
sixtyfootersdude
Sin embargo, una nota: If you are ok with hooks, than useEffect as Asaf Aviv suggested is a very clean way to do it.- Esto no resuelve el problema: a pesar de tener 4 votos, esa pregunta no restablece el estado en el cambio de accesorios.
sixtyfootersdude
useEffect(() => { setSelectedIndex(0) }, [items]) El segundo argumento de UseEffect define cuándo debe ejecutarse, de manera tan efectiva que se ejecutará setSelectedIndex(0)cada vez que los elementos cambien. Cuál es su requisito hasta donde yo entiendo.
Alon Bar David
@sixtyfootersdude useEffecten mi solución restablecerá el índice cuando namescambie, Tunn's useEffectno es válido y mostrará errores de linting y arrojará errores cuando cambien los nombres.
Asaf Aviv
Vale la pena mencionar que si no se restablece para usted, está mutando namesdirectamente, lo cual es otro problema.
Asaf Aviv