React Hooks

React = programmation fonctionnelle ...

Données

HTML

... pas toujours

Données

HTML

State

XHR

Event handlers

Solution 1 : Lifecycle

Mount Update Unmount
constructor()
getDerivedStateFromProps()
render()
componentDidMount()
getDerivedStateFromProps()
shouldComponentUpdate()
render()
getSnapshotBeforeUpdate()
componentDidUpdate()
componentWillUnmount()

Déclencher des side effects

Utiliser du state

State
this.state
this.setState()

Autrement dit ...

Exemple de bug

export class MessageList extends React.Component<Props> {
  componentDidMount() {
    this.props.fetchMessages(this.props.userId, this.props.folderId);
  }

  render() {
    return (
      <ul>
        {this.props.messages.map(message => (
          <li key={message.id}>{message.text}</li>
        ))}
      </ul>
    );
  }
}

Qui voit un bug possible ?

Si props.folderId change (ex navigation de page), on ne va pas chercher la nouvelle liste de messages !

Correction

export class MessageList extends React.Component<Props> {
  componentDidMount() {
    this.props.fetchMessages(this.props.userId, this.props.folderId);
  }

  componentDidUpdate(prevProps: Props) {
    if (prevProps.userId !== this.props.userId || prevProps.folderId !== this.props.folderId) {
      this.props.fetchMessages(this.props.userId, this.props.folderId);
    }
  }

  render() {
    return (
      <ul>
        {this.props.messages.map(message => (
          <li key={message.id}>{message.text}</li>
        ))}
      </ul>
    );
  }
}

Code dupliqué ...

Un autre pattern ?

Comment rester en programmation fonctionelle tout en permettant le state et les side effects ?

Les hooks !

React 16.8

useState

const MessageList: React.FunctionComponent<Props> = ({ messages }) => {
  const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);

  return (
    <ul>
      {messages.map(message => (
        <li
          key={message.id}
          className={message.id === selectedMessageId ? 'selected' : ''}
          onClick={() => setSelectedMessageId(message.id)}
        >
          {message.text}
        </li>
      ))}
    </ul>
  );
};
const [value, setter] = useState<valueType>(initialValue);

useState - 2

const MessageList: React.FunctionComponent<Props> = ({ messages }) => {
  const [selectedMessageIds, setSelectedMessageIds] = useState<number[]>([]);

  const addToSelection = (newMessageId: number) => {
    setSelectedMessageIds([...selectedMessageIds, newMessageId]);
  };

  const removeFromSelection = (messageIdToRemove: number) => {
    setSelectedMessageIds(selectedMessageIds.filter(messageId => messageId !== messageIdToRemove));
  };

  const toggleSelected = (messageId: number) => {
    return selectedMessageIds.includes(messageId)
      ? removeFromSelection(messageId)
      : addToSelection(messageId);
  };

  return (
    <ul>
      {messages.map(message => (
        <li
          key={message.id}
          className={selectedMessageIds.includes(message.id) ? 'selected' : ''}
          onClick={() => toggleSelected(message.id)}
        >
          {message.text}
        </li>
      ))}
    </ul>
  );
};

useEffect

const MessageList: React.FunctionComponent<Props> = ({
  userId,
  folderId,
  messages,
  fetchMessages,
}) => {
  useEffect(
    () => {
      fetchMessages(userId, folderId);
    },
    [userId, folderId],
  );

  return (
    <ul>
      {messages.map(message => (
        <li key={message.id}>{message.text}</li>
      ))}
    </ul>
  );
};
useEffect(() => {
  doStuff();

  return () => { undoStuff(); }; // optionnel
}, [trigger1, trigger2]);

useEffect

useEffect(() => {
  doStuff();

  return () => { undoStuff(); }; // optionnel
}, [trigger1, trigger2]);

Qu'est-ce que ça fait ?

  • S'exécute après le render et le paint (non bloquant)
  • Le cleanup (éventuel) s'exécute avant l'effet
  • Si des triggers sont précisés, l'effet n'est exécuté que si les triggers ont changé (en référence)

useEffect - Exemples

useEffect(() => {
  document.title = 'Liste de messages';
}, []);

Sans dépendance (au mount)

useEffect(() => {
  setRenderCount(renderCount + 1);
});

Systématique

Avec cleanup

// Dans un composant de live chat avec une WebSocket

useEffect(() => {
  const listener = socket.addEventListener(
    'message',
    (message) => setMessageList([...messageList, message])
  );
  
  return () => { 
    socket.removeEventListener('message', listener);
  }
}, []);

Que fait ce useEffect ?

// Dans un formulaire Formik

useEffect(
  () => {
    if (values.organisationType !== null && values.companyLegalForm) {
      if (
        values.organisationType.id === ORGANISATION_TYPE_ID.PHYSICAL_PERSON &&
        !physicalEntityList.includes(values.companyLegalForm.id)
      ) {
        setFieldValue('companyLegalForm', null);
      }
      if (
        values.organisationType.id === ORGANISATION_TYPE_ID.CORPORATE_ENTITY &&
        !corporateEntityList.includes(values.companyLegalForm.id)
      ) {
        setFieldValue('companyLegalForm', null);
      }
    }
  },
  [values.organisationType],
);

Effet du menu déroulant "organisationType" sur les options du  menu déroulant "companyLegalForm"

Un composant à transformer ...

class AdministrativeDivisionInput extends React.PureComponent<Props> {
  state: State = {
    administrativeDivision: {
      region: this.props.value ? this.props.value.province.region : undefined,
      province: this.props.value ? this.props.value.province : undefined,
      commune: this.props.value,
    },
    provinceList: [],
    communeList: [],
  };

  componentDidMount() {
    this.init(this.props);
  }

  componentWillReceiveProps(nextProps: Props) {
    this.init(nextProps);
  }

  init = (props: Props) => {
    const { region, administrativeEntitiesById, value } = props;
    const {
      regionById,
      provinceListByRegionId,
      communeListByProvinceId,
    } = administrativeEntitiesById;

    if (region && !isEmpty(regionById)) {
      let regionFromCommune: Region | null = null;
      let province: Province | null = null;
      let commune: Commune | null = null;
      let provinceList: Province[] = [];
      let communeList: Commune[] = [];
      if (value) {
        commune = value;
        province = commune.province;
        regionFromCommune = regionById[province.region.id];
        provinceList = provinceListByRegionId[regionFromCommune.id];
        communeList = communeListByProvinceId[province.id];
      } else {
        regionFromCommune = regionById[region.id];
        provinceList = provinceListByRegionId[regionFromCommune.id];
        communeList = [];
      }
      this.setState((state: State) => ({
        ...state,
        administrativeDivision: {
          region: regionFromCommune,
          province,
          commune,
        },
        provinceList,
        communeList,
      }));
    }
  };

  handleChange = (
    event: React.ChangeEvent<{ name?: string | undefined; value: unknown }>,
    type: string,
  ) => {
    const { setFieldValue, field, administrativeEntitiesById } = this.props;
    const {
      regionById,
      provinceById,
      communeById,
      provinceListByRegionId,
      communeListByProvinceId,
    } = administrativeEntitiesById;
    const { administrativeDivision } = this.state;
    const entityId = event.target.value as string;
    switch (type) {
      case 'region':
        const prevRegion = administrativeDivision.region ? administrativeDivision.region : null;
        if (prevRegion === null || entityId !== prevRegion.id.toString()) {
          this.setState((state: State) => ({
            ...state,
            administrativeDivision: {
              region: regionById[entityId],
              province: null,
              commune: null,
            },
            provinceList: provinceListByRegionId[entityId],
            communeList: [],
          }));
        }
        break;
      case 'province':
        const prevProvince = administrativeDivision.province
          ? administrativeDivision.province
          : null;
        if (prevProvince === null || entityId !== prevProvince.id.toString()) {
          this.setState((state: State) => ({
            ...state,
            administrativeDivision: {
              ...administrativeDivision,
              province: provinceById[entityId],
              commune: null,
            },
            communeList: communeListByProvinceId[entityId],
          }));
        }
        break;
      case 'commune':
        const prevCommune = administrativeDivision.commune ? administrativeDivision.commune : null;
        if (prevCommune === null || entityId !== prevCommune.id.toString()) {
          const commune = communeById[entityId];
          this.setState((state: State) => ({
            ...state,
            administrativeDivision: {
              ...administrativeDivision,
              commune,
            },
          }));
          setFieldValue(field.name, commune);
        }
        break;
    }
  };

  render() {
    const { intl, regionList, field, value, region, classes, error, showRegion } = this.props;
    const { administrativeDivision, provinceList, communeList } = this.state;

    return (
      <>
        {(!region || showRegion) && (
          <FormControl
            classes={{
              root: classes.container,
            }}
            disabled={!regionList || regionList.length === 0}
          >
            <InputLabel classes={{ root: classes.label }}>
              {intl.formatMessage({ id: 'common.administrative.division.region' })}
            </InputLabel>
            <Select
              classes={{ root: classes.content }}
              onChange={event => this.handleChange(event, 'region')}
              value={administrativeDivision.region ? administrativeDivision.region.id : ''}
              inputProps={{
                name: `${field.name}-region`,
              }}
            >
              {regionList.map((regionOption: Region) => (
                <MenuItem key={regionOption.id} value={regionOption.id}>
                  {regionOption.name}
                </MenuItem>
              ))}
            </Select>
          </FormControl>
        )}
        <FormControl
          classes={{
            root: classes.container,
          }}
          disabled={!provinceList || provinceList.length === 0}
        >
          <InputLabel classes={{ root: classes.label }}>
            {intl.formatMessage({ id: 'common.administrative.division.province' })}
          </InputLabel>
          <Select
            classes={{ root: classes.content }}
            onChange={event => this.handleChange(event, 'province')}
            value={administrativeDivision.province ? administrativeDivision.province.id : ''}
            inputProps={{
              name: `${field.name}-province`,
            }}
          >
            {provinceList.map((province: Province) => (
              <MenuItem key={province.id} value={province.id}>
                {province.name}
              </MenuItem>
            ))}
          </Select>
        </FormControl>
        <FormControl
          classes={{
            root: classes.container,
          }}
          disabled={!communeList || communeList.length === 0}
        >
          <InputLabel classes={{ root: error ? classes.labelError : classes.label }}>
            {intl.formatMessage({ id: 'common.administrative.division.commune' })}
          </InputLabel>
          <Select
            classes={{ root: classes.content }}
            onChange={event => this.handleChange(event, 'commune')}
            value={value ? value.id : ''}
            inputProps={{
              name: field.name,
            }}
          >
            {communeList.map((commune: Commune) => (
              <MenuItem key={commune.id} value={commune.id}>
                {commune.name}
              </MenuItem>
            ))}
          </Select>
        </FormControl>
      </>
    );
  }
}

export default withStyles(AdministrativeDivisionStyle)(AdministrativeDivisionInput);

useLayoutEffect + useRef

  • S'exécute entre le render et le paint
  • Permet de manipuler les éléments de la page avant que le navigateur ne les dessine
  • Ajouter des event listeners

Règles des Hooks

Règle Raison
Toujours appeler les hooks dans la fonction render, pas dans des boucles ou des conditions. React a besoin que les hooks soient toujours appelés dans le même ordre.
Toujours appeler les hooks dans le composant ou dans un hook custom. React sauvegarde l'état des hooks sur le composant.
Pas de hooks dans une classe. Ne pas mélanger la mémoire des hooks et le state de la classe.
Convention de nommage : un hook commence par "use". Exemple : useEffectOfNationalityOnDocumentType Pour reconnaître un hook au premier coup d'oeil.

Sources

React Hooks

By Foucauld Degeorges

React Hooks

  • 586