Search code examples
reactjstypescriptreact-hooksdebouncing

Why is setting a React component in useState a bad practice?


Why is it bad practice to store a component as the state value?

const [Component, setComponent] = useState<JSX.Element>(<Empty />);

Say I want to conditionally render a component based off a number of different criteria (all mutually exclusive). But before actually rendering, I would like to add a debouncer (delayed rendering after x ms of inactivity). I wouldn't necessarily do this as a go-to, but it seems more intuitive and is less code to just assign the component as the state value (in this scenario). I could set up my state to hold a text value, reference that everywhere and set up a map variable to map the string to a component. But it seems unnecessary. I've read online that it's bad practice, and you should only put data in state, but everyone seems to convenient leave out the why it's a bad practice. Nothing in the docs seems to indicate this is bad practice either.

Here's an example that works, hopefully illustrating why setting components in state is convenient. Each of the Message components are memoized with React.memo so there's no chance of their props changing:

import React, { useState, useEffect } from 'react';
import useDebounce from '../../hooks/useDebounce';
import {
  TooShort,
  NoPrompt,
  LimitWarning,
  LimitReached,
  Empty,
} from './Messages';

interface Props {
  promptAreaTouched: boolean;
  promptText: string;
  debounceTimeout?: number;
}

const DEBOUNCE_TIMEOUT = 2000;
const SHORT_PROMPT_LENGTH = 5;
const APPROACHING_PROMPT_LENGTH = 40;
const MAX_PROMPT_LENGTH = 50;

const PromptLengthMessage = ({
  promptAreaTouched,
  promptText,
  debounceTimeout = DEBOUNCE_TIMEOUT,
}: Props) => {
  const [Component, setComponent] = useState<JSX.Element>(<Empty />);
  const DebouncedComponent = useDebounce(Component, debounceTimeout);

  const numWords = promptText.split(/\s+/).length;

  const isEmpty = promptAreaTouched && promptText.length === 0;
  const isTooShort = promptAreaTouched && numWords <= SHORT_PROMPT_LENGTH;
  const limitWarning =
    numWords >= APPROACHING_PROMPT_LENGTH && numWords < MAX_PROMPT_LENGTH;
  const limitReached = numWords >= MAX_PROMPT_LENGTH;

  useEffect(() => {
    switch (true) {
      case isEmpty:
        setComponent(<NoPrompt />);
        break;
      case isTooShort:
        setComponent(<TooShort />);
        break;
      case limitWarning:
        setComponent(<LimitWarning />);
        break;
      case limitReached:
        setComponent(<LimitReached />);
        break;
      default:
        setComponent(<Empty />);
    }
  }, [promptText]);

  return DebouncedComponent === Component ? DebouncedComponent : <Empty />;
};

export default PromptLengthMessage;

Solution

  • State is the word you are saying, not the shapes of your mouth as you say it.

    State is a book, not how it looks on the bookshelf.

    State is what is wrong with your message, not how that warning is presented.

    What you render is not state. Instead, what you render is informed by state.

    State is meaning. And that when that meaning changes, React reacts to that change by declaratively rendering different content.


    Setting rendered content into state is imperative code. It's you saying when I click this button I want to render this content.

    That may sound fine at first, but that's like doing this:

    button.onClick = () => document.getElementById('message').innerHtml = 'Too Short!'
    

    But what is better is:

    When I click this button, I want to change the state of my UI, and then the UI will render itself correctly. This is declarative code. It describes how the UI will be rendered in various states.

    That might look like this:

    <button onClick={() => setMessage('tooShort')} />
    {
      message === 'tooShort' && (
        <p>your message <code>{enteredText}</code> is too short</p>
      )
    }
    

    Now setting new state is trivial, and what is rendered is derived from that state.

    In React, declarative code is good, and imperative code is to be avoided.


    So when you do this you can't use that state for anything, except spitting back out somewhere.

    Below are a few things that are much harder to do if you put JSX in state.


    You can't use that state to inform multiple places of rendering. Say you have the message, but you also have many of these fields and you want to count the errors, and you want to exclude Empty from that count. You can't if it's JSX.


    Harder to test. Say you wanted to make this a custom hook and cover with a test. A good test that you can't do might be:

    expect(useMessageError('a').error).toBe('tooShort')
    

    Hook dependencies.

    useEffect(() => {}, [myComponentState]) // may re-render since <></> !== <></>
    useEffect(() => {}, [myStringState]) // stable because 'empty' === 'empty'
    

    I think you will find that if you try make anything that touches this code even a little bit more complex, this approach will start to fall apart. And when used a lot this code will devolve into a big mess.

    My advice is to embrace React's declarative paradigm. It's setup this way for a good reason. Let your state hold data and meaning, and let your rendering do what it does best, which is render based on that state.