Avatar

Jambo, I'm Julia.

Generating Multiple React useState Hooks For Multiple State Variables

#hooks #react #typescript

6 min read

React state hooks are great for managing state locally, within a single component. I love how simple it all is.

import React, { useState } from 'react'

function HabitTracker() {
  // Declare a new state variable, called "numberRuns"
  const [numberRuns, setNumberRuns] = useState(0)

  return (
    <div>
      <p>Julia went for {numberRuns} runs this week.</p>
      <button onClick={() => setNumberRuns(numberRuns + 1)}>Another run</button>
    </div>
  )
}

If there are more than one state variable you want to monitor, it's simple enough to add a second or third useState hook.

import React, { useState } from 'react'

function HabitTracker() {
  const [numberRuns, setNumberRuns] = useState(0)
  const [numberManga, setNumberManga] = useState(0)
  const [numberDrummingHours, setNumberDrummingHours] = useState(0)

  return (
    <div>
      <p>Julia went for {numberRuns} runs this week.</p>
      <p>Julia read {numberManga} manga this week.</p>
      <p>Julia did {numberDrummingHours} hours of drum practice this week.</p>
      <button onClick={() => setNumberRuns(numberRuns + 1)}>Another run</button>
      <button onClick={() => setNumberManga(numberManga + 1)}>Another manga</button>
      <button onClick={() => setNumberDrummingHours(numberDrummingHours + 1)}>Another hour on drums</button>
    </div>
  )
}

But what happens if you need to add even more state variables? We can carry on like this but there might be a cleaner way to do this. The problem I needed to solve involved tracking 20 state variables - the method above definitely would've been a bit unwieldy. Is there a way of generating useState hooks from a constant?

It turns out that the answer is yes. Here's the solution I ended up with (note that what I'm trying to do here is to show/hide a hobby question in the DOM). The idea behind the solution was to:

  1. Set up a single object (hobbyStates) to store all the various hobby states (in this case, to either show/hide the hobby as a question).
  2. Similarly, set up a single object setHobbyStates to store all the state setting functions for each hobby.
  3. Save all the various hobbies you want to track the state of in an array of objects (const HOBBIES).
  4. To make iterating through the HOBBIES list easier, I created a new questionOrder variable which just provides me with an array of the stateName variable for each hobby in HOBBIES.
  5. Because I wanted the first hobby question to be displayed upon render, I set the first item in my questionOrder array (in this case, running) to have a state of show .
  6. I then iterated through the rest of the questionOrder array and set the states for each remaining hobby to an empty string '' (in the child component, this hides the question).
const HOBBIES = [
  { question: 'How many times did you run?', stateName: 'running' },
  { question: 'How many manga comics did you read?', stateName: 'manga' },
  {
    question: 'How many hours did you spend on the drums?',
    stateName: 'drums',
  },
]

const questionOrder: string[] = HOBBIES.map((hobby) => {
  return hobby.stateName
})

export const HobbyForm: React.FC = () => {
  // set up 1st state hook for the first question, so that it displays a "show" state
  const [hobbyState, setHobbyState] = useState('show')

  // create a hobbyStates object that will store all the individual hobbyState variables.
  // set the first key-value pair for running
  const hobbyStates: { [key: string]: string } = {
    [questionOrder[0]]: hobbyState,
  }

  // similarly, do the same by setting up a setHobbyStates object
  const setHobbyStates: { [key: string]: Dispatch<string> } = {
    [questionOrder[0]]: setHobbyState,
  }

  // now set up the remaining questions' state hooks as ''. This hides the question as you'll
  // see in the HobbyQuestion component below.
  for (let i = 1; i < questionOrder.length; i++) {
    const [hobbyState, setHobbyState] = useState('')
    hobbyStates[questionOrder[i]] = hobbyState
    setHobbyStates[questionOrder[i]] = setHobbyState
  }

  return (
    <>
      {HOBBIES.map((hobby) => {
        return (
          <HobbyQuestion
            key={hobby.stateName}
            question={hobby.question}
            status={hobbyStates[hobby.stateName]}
            updateStatus={setHobbyStates[hobby.stateName]}
          />
        )
      })}
    </>
  )
}

From here, I return a HobbyQuestion component that receives a status and updateStatus prop. I won't write out the whole component below, but it could look something like this. Note how the updateStatus callback is called upon choosing an answer to each hobby question. What this does it to setHobbyStates[hobby.stateName] to a value of the RadioButton value selected.

export const HobbyQuestion: React.FC<HobbyQuestionProps> = ({ question, status, updateStatus }) => {

  ...

  const FREQUENCY_CHOICES = (
    <RadioButtonGroup>
      <div className="grid gap-2 grid-cols-2">
        <RadioButton clicked={updateStatus} ... e.g. 1x a week />
        <RadioButton clicked={updateStatus} ... e.g. 2x a week />
        <RadioButton clicked={updateStatus} ... e.g. 3x a week />
        <RadioButton clicked={updateStatus} ... e.g. 4x a week />
      </div>
    </RadioButtonGroup>

  const displayChoices = (status: string) => {
    if (status === 'show') {
      return FREQUENCY_CHOICES
    } else {
      // i.e. if status === ''
      return null
    }
  }

	return (
	    <>
	      <div className="mb-4 flex align-middle">
	        <p className="text-base-secondary light">
	          {question}
	        </p>
	      </div>
	      {displayChoices(status)}
	    </>
	  )
}

When a hobby question has been answered, we want to hide it and show the next question. To do this, we need to amend the original HobbyForm component and add a useEffect hook.

// other values this could take is an answer like '1x_a_week'
const INVALID_STATUSES = ['', 'show']

useEffect(
  () => {
    // run through questions and check for next question in line to update to 'show' state
    for (let i = 0; i < questionOrder.length - 1; i++) {
      const currentQuestion = questionOrder[i]
      const nextQuestion = questionOrder[i + 1]

      !INVALID_STATUSES.includes(hobbyStates[currentQuestion]) && INVALID_STATUSES.includes(hobbyStates[nextQuestion])
        ? setHobbyStates[nextQuestion]('show')
        : null
    }
  },
  questionOrder.map((question) => {
    return hobbyStates[question]
  }),
)

If you're wondering what the last bit of code with questionOrder.map... is about, that's the dependency array that you should include whenever you want useEffect to only re-run when when specific values change (rather than on every re-render). Here's what it normally looks like - I just used a map to render all of various hobby state variables automatically rather than manually typing each of them out. 🤖

useEffect(() => {
  document.title = `You clicked ${count} times`
}, [count]) // Only re-run the effect if count changes

I've also written a follow up post on how to generate a Yup validation object dynamically, if you're interested.

© 2016-2024 Julia Tan · Powered by Next JS.