LazioTibijczyk
LazioTibijczyk

Reputation: 1937

Order of hooks error when rendering different components

React throws the following error when I am trying to render different components

Warning: React has detected a change in the order of Hooks called by GenericDialog. This will lead to bugs and errors if not fixed.

Previous render Next render
useRef useRef
useState useState
useState useState
useState useState
useState useState
useState useState
useContext useState

I do agree this would be inappropriate when I would be rendering the same component each time but with different order of hooks. What I am trying to achieve is render a different component each time so it is quite obvious the order of hooks won't be identical.

I have created this GenericDialog component which renders a multistep dialog.

import React, { useRef, useState, useEffect } from 'react';
import { DialogFooterNavigation } from './DialogFooterNavigation';
import { Dialog } from '../../../../Dialog';
import { Subheader } from '../../../../Subheader';
import { Loading } from '../../../../Loading';

export interface FooterConfiguration {
  onContinue?: () => Promise<boolean | void>;
  isContinueDisabled?: boolean;
  continueLabel?: string;
  isBackHidden?: boolean;
  isCancelHidden?: boolean;
}

export interface HeaderConfiguration {
  subheader?: string;
}
export interface DialogStepProps {
  setHeaderConfiguration: (config: HeaderConfiguration) => void;
  setFooterConfiguration: (config: FooterConfiguration) => void;
}

export type DialogStep = (props: DialogStepProps) => JSX.Element;

interface GenericDialogProps {
  isShown: boolean;
  hideDialog: () => void;
  steps: DialogStep[];
  header: string;
}

export const GenericDialog = ({
  isShown,
  hideDialog,
  steps,
  header,
}: GenericDialogProps) => {
  const buttonRef = useRef(null);
  const [step, setStep] = useState<number>(0);
  const [isLoading, setIsLoading] = useState<boolean>(false);

  const [headerConfiguration, setHeaderConfiguration] = useState<HeaderConfiguration | undefined>(
    undefined,
  );
  const [footerConfiguration, setFooterConfiguration] = useState<FooterConfiguration | undefined>(
    undefined,
  );
  const [loadingMessage, setLoadingMessage] = useState<string>('');

  const dialogBody = steps[step]({
    setHeaderConfiguration,
    setFooterConfiguration,
  });

  const nextStep = () => {
    if (step < steps.length - 1) {
      setStep(step + 1);
    }
  };

  const prevStep = () => step > 0 && setStep(step -1);

  const isBackPossible = step > 0;
  const onBack = () => (isBackPossible || footerConfiguration?.isBackHidden ? undefined : prevStep);

  const onContinue = async () => {
    setIsLoading(true);

    const result = await footerConfiguration?.onContinue?.call(undefined);
    setIsLoading(false);

    if (result === false) {
      return;
    }

    nextStep();
  };

  return (
    <Dialog isShown={isShown} onHide={hideDialog}>
      <div>
        {header}
        {headerConfiguration?.subheader && (
          <Subheader>{headerConfiguration.subheader}</Subheader>
        )}
      </div>

      {isLoading && loadingMessage ? <Loading msg={loadingMessage} /> : dialogBody}

      {!isLoading && (
        <DialogFooterNavigation
          onBack={isBackPossible ? onBack : undefined}
          onContinue={onContinue}
          isContinueDisabled={footerConfiguration?.isContinueDisabled}
        />
      )}
    </Dialog>
  );
};

const FirstStep = (props: DialogStepProps) => {
  // Here I need useContext
  const { id, name } = useCustomerContext();
  
  useEffect(() => {
    props.setFooterConfiguration({
      isContinueDisabled: !id || !name,
    })
  }, [id, name]);
  
  return (
    <>
      <div>ID: {id}</div>
      <div>Name: {name}</div>
    </>
  );
};

const SecondStep = (props: DialogStepProps) => {
  // Here I don't need useContext but I do need useState
  const [inputValue, setInputValue] = useState({});
  
  useEffect(() => {
    props.setFooterConfiguration({
      isContinueDisabled: !inputValue,
    });
  }, [inputValue]);
  
  return <input value={inputValue} onChange={(event) => setInputValue(event.target.value)} />;
}


const MyDialogExample = () => {
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  
  const steps: DialogStep[] = [
    FirstStep,
    SecondStep,
  ];
  
  return (
    <>
      <button onClick={() => setIsDialogOpen(true)}>Open Dialog</button>
      
      <GenericDialog
        isShown={isDialogOpen}
        hideDialog={() => setIsDialogOpen(false)}
        steps={steps}
        header="Dialog example"
      />
    </>
  );
};

Upvotes: 1

Views: 788

Answers (1)

Ernesto Stifano
Ernesto Stifano

Reputation: 3130

The problem is here:

const dialogBody = steps[step]({
  setHeaderConfiguration,
  setFooterConfiguration,
});

Try changing it to something like this:

const DialogBody = steps[step];

And then, in your return statement:

{isLoading && loadingMessage ? <Loading msg={loadingMessage} /> : <DialogBody setHeaderConfiguration={setHeaderConfiguration} setFooterConfiguration={setFooterConfiguration} />}

Please note that it can be done differently, like:

const DialogBody = steps[step];

const dialogBody = <DialogBody setHeaderConfiguration={setHeaderConfiguration} setFooterConfiguration={setFooterConfiguration} />;

And keeping your return statement unaltered.

Explanation

Your code isn't entirely wrong though. When working with functional components, there is a subtle difference between an actual component, a hook and a simple function that returns an instantiated component based on some logic. The problem is that you are mixing those three.

You can't manually instantiate a component by calling its corresponding function (just like you can't instantiate a class component by using the new operator). Either you use JSX (like <DialogBody />) or directly use React inner methods (Like React.createElement()). Both alternatives are different from just doing dialogBody(). For example, if you see the compiled JSX code you will note that <DialogBody /> compiles to code that uses React.createElement() and the latter returns a real React element instance containing many special properties and methods.

dialogBody() would work if its only goal was to return an instantiated element (Using one of the methods above) based on some logic. This implies not using any hook along with some other constraints.

Instead, your dialogBody 'function' contains hooks and it acts as a custom hook itself. This is why React complains about hooks execution order. You are executing hooks conditionally.

Upvotes: 3

Related Questions