DEV Community

Alexis Moody
Alexis Moody

Posted on • Updated on

Advanced State Management With Dynamic Input Elements and Hooks

In this series we will be talking about the practical applications of common patterns in React. I've always found 'getting started' tutorials to be too abstract so I wanted to put together examples of problems I've solved in React.

Today we'll be talking about advanced state management through multiple input elements that are completely dynamic. And when I say dynamic I mean that we can't make an assumption on the availability of these inputs, and they can be adjusted at any time. We'll be using typescript in our components but the overall focus will be on the React patterns.

If you want to skip straight to the solution you can check it out here: https://codesandbox.io/s/dynamic-inputs-example-gsdbc

Mo Money, Mo Problems

Let's say we are tasked with creating a create/edit form for an account that can have multiple social media links attached to it. All of these links are optional and can be added/removed/updated at will. Our designer has come back with a design that may look something like this:

two input html elements with a red remove buttons on the right and blue add button on bottom left corner

The blue plus button adds a new row of inputs and the red minus icon removes the input. There should always be at least one input available in the form and each input should be able to be updated at the same time.

Breakin' It Down

So let's take a moment to identify the functional components that make up this design.

  1. A container that renders the inputs
  2. The inputs themselves
  3. The 'remove' button
  4. The 'add' button

We can disregard the 'Social Media' label as it's just presentational and doesn't really effect the functionality of these inputs. We'll move through this tutorial one step at a time, starting with the container that renders all of the inputs.

One Container to Rule Them All

An important aspect of state management in React is understanding where state should be managed in the first place. Every component, especially now with hooks, can manage its own state. But the age old adage of just because you can do something, doesn't mean you should do something is important to remember. We know that the container will be the source of truth for how many inputs will be rendered, but should we be managing state there? Let's get started with the basics of this component:

import React, { ChangeEvent } from 'react';

interface MultiInputProps {
  values?: string[];
  onChange: (newValues: string[]) => void;
}

const MultiInputContainer = (props: MultiInputProps) => {
  const { values, onChange } = props;

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    onChange([...values, e.target.value])
  }

  return (
    <div>
      {
        values.map((value) => {
          return (
            <input
              key={value}
              value={value} 
              placeholder="https://dev.to"
              onChange={handleChange}
            />
          )
        })
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

So this looks alright. We have a container rendering multiple inputs based on values being passed to it. We just need to add a couple more buttons and we're done, right? Well, not so fast. There are a couple of problems with how this component works at the moment.

First in our handleChange function we are taking the current values, spreading them into a new array along with the inputted value, and passing that along to our onChange function prop. The issue there is that every new input by the user will be a new entry in the array. You'll end up with something like: ['www.first.com', 'w', 'ww', 'www', 'www.', ...]. Also, because we are directly rendering the values as inputs we will wind up with a new input for every letter that was entered by the user. That's not really what we want. So we need a way to identify which input is being updated. Enter our friends useState and useEffect! Let's take a look at improvements with these hooks.

import uniqueId from 'lodash/uniqueId'

type InputConfig = {
  value: string;
  id: string;
}

const emptyInput = (): InputConfig => ({
  id: uniqueId('input'),
  value: '',
})

const generateInputConfig = (values: string[]): InputConfig[] => {
  return values.map((value) => {
    return {
      id: uniqueId('input'),
      value
    }
  })
}

const MultiInputContainer = (props: MultiInputProps) => {
  const { values, onChange } = props;
  const [inputConfigs, setInputConfigs] = useState<InputConfig[]>()

  useEffect(() => {
    if (inputConfigs.length) return

    let initialInputs: InputConfig[]
    if (!values || !values.length) {
      initialInputs = [emptyInput()]
    } else {
      initialInputs = generateInputConfig(values)
    }

    setInputConfigs(initialInputs)    
  }, [values, inputConfigs])

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target
    const newValues: string[] = []
    const newInputs = inputConfigs.map((input) => {
      newValues.push(input.value)
      if (input.id !== name) return input;

      return { ...input, value }
    })

    onChange([...newValues, value])
    setInputConfigs(newInputs)
  }

  return (
    <div>
      {
        inputConfigs.map((config) => {
          return (
            <input
              key={config.id}
              value={config.value}
              name={config.id} 
              placeholder="https://dev.to"
              onChange={handleChange}
            />
          )
        })
      }
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Awesome! So by managing the state of input "configurations" with a custom type we can more reliably change the value of an input without effecting the state of the other inputs. We've added a few helper functions to generate these configurations, emptyInput and generateInputConfigs, but let's break down what is happening in each part of the component lifecycle.

First, when the component is rendered, the useEffect hook looks at all of the values that have been passed as a prop. If values don't exist or there are no strings in the array we generate a new emptyInput and set that as the input configs. Otherwise we look at all of the values and generate the initial inputs based on those values. We skip all of this logic if there are already inputConfigs set.

Second, when an input value is changed, the handleChange function only updates the value associated with the correct id while keeping everything in place by using map. If we just pushed the new config to an array the input could move spots on the screen. We also ensure the component that is using these inputs gets the updated values by calling the onChange prop. The placement of these values doesn't matter quite as much as they are not being directly rendered on the screen.

But why did we do this in the container component? Could we have accomplished the same thing by managing state in the Input component that we are about to implement? Sure, we could manage an html input's state within the Input component. But we would need to be able to add and remove the inputs so you still need to know the current unique ids within the container. So you would be increasing the surface area of state in this component structure and not really buying yourself gains in readability or performance. Going back to my initial point, just because you can do something doesn't mean you should.

We've now got a really solid structure for rendering multiple string inputs that can be updated. But what about adding or removing the inputs? Let's move on to the input component!

In-N-Out

Right now we just have an html input element, but we'll need to add a button to remove that input from the DOM. Let's move this input element and its 'remove' button to a new function component so we can separate our concerns a little better, and make it a littler easier to read in the long run.

import React, { ChangeEvent } from 'react'

interface InputProps {
  value: string;
  onChange: (value: string, id: string) => void;
  id: string;
  onRemove: (id: string) => void;
  placeholder?: string;
}

const Input = (props: InputProps) => {
  const { value, onChange, id, onRemove, placeholder } = props

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    onChange(e.target.value, id)
  }

  const handleRemove = () => {
    onRemove(id)
  }

  return (
    <div>
      <input
        placeholder={placeholder}
        value={value} 
        onChange={handleChange} 
      />
      <button type="button" onClick={handleRemove}>
        Remove
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

So this is pretty straight forward. We've just moved the input element to its own component that renders its own remove button and has a couple of handlers for input change and removal. The other change you'll notice is that we are no longer using name in the input element. That's mostly just a personal choice, you could use the id prop as name and look for that in the event target. I would just argue that by doing that you're duplicating information that is already available.

Now that we've split this component out what do we need to do with our MultiInputContainer? Let's take a look at those adjustments:

import Input from './Input'

const MultiInputContainer = (props: MultiInputProps) => {
  ...

  const handleRemove = (id: string) => {
    const filteredInputs = inputConfigs.filter((input) => (
      input.id !== id
    ))
    setInputConfigs(filteredInputs)
  }

  const renderInput = (input) => (
    <Input 
      key={input.id}
      value={input.value}
      id={input.id}
      onChange={handleChange}
      onRemove={handleRemove}
      placeholder="https://example.com"
    />
  )

  return (
    <div>
      {inputConfigs.map(renderInput)}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Great! In the handleRemove function we're taking the id of the input we want to remove and filtering the input config array to only include inputs that are not equal to the id passed to the function. This is a common technique for removing elements in an array, especially for arrays of objects like inputConfigs. You could also use splice to delete the element like this, but in my opinion it's a little too verbose for this use case:

const handleRemove = (id: string) => {
  const copiedArr = [...inputConfigs]
  const indexToRemove = copiedArr.findIndex((el) => el.id === id)
  copiedArr.splice(indexToRemove, 1)
  setInputConfigs(copiedArr)
}
Enter fullscreen mode Exit fullscreen mode

We've got one last portion of this functionality to add...the add button!

Adding It All Together

The last piece of this puzzle is the ability to add a new input. Thankfully we've already done a big portion of the work when we implemented the emptyInput function earlier. Now we can place the button in the component and when it's clicked we append a new emptyInput to the array of inputConfigs.

const MultiInputContainer = (props: MultiInputProps) => {
  ...

  const handleAdd = () => {
    setInputConfigs([...inputConfigs, emptyInput()])
  }

  return (
    <div>
      {inputConfigs.map(renderInput)}
      <button onClick={handleAdd}>Add</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

And that's it! We now have a system of inputs that can be added, removed, and edited in any form throughout your application. You can view the working component here. I'd love to hear your thoughts on these components. What if we had inputs that had slightly different rendering requirements, like addresses or types associated to an account? Could we make this structure a little more reusable? Let's discuss in the comments!

Top comments (3)

Collapse
 
devhammed profile image
Hammed Oyedele • Edited

I will just use Formik for this instead of reinventing the wheel but nice article anyway.

Collapse
 
afrodevgirl profile image
Alexis Moody

Sure, you can use a FieldArray with Formik to accomplish the design requirements. Although I've personally found Formik to be an absolute beast to deal with, especially on small projects or small forms. That said, this tutorial is less about what we're building and more about how to think about state management with a "real world" example.

Collapse
 
devhammed profile image
Hammed Oyedele

Oh Great 👍