Different Approaches to Form Element State Management in React

This post concerns approaches to create managing the value of state of form elements in React.

Fully working examples of these approaches are available here.

Primarily, we are concerned about elements where the user is selecting from a list of pre-determined options, eg

At a high level these components need to handle the following facets:

  1. We need to be able to provide a list of items to populate the list of options
  2. We need to be able to generate a user-friendly label for each option
  3. We need to be able to return a data-model-friendly value for a selected option
  4. We need to be able to populate the section state from some existing data.
  5. We may want to use elements inside a browser native form, and have the values be accessable in the forms submission event.

On points 2 and 3, for example we might have some data like:

[
  {
      id: "1", 
      fName: "Joe", 
      lName: "Bloggs", 
      address: "2 Foobar Lane"
  }, 
  {
    id: "2", 
    fName: "Jane", 
    lName: "Smith", 
    address: "4 Barfloo Walk"
  }
]

We'd want to populate the list of options like:

But the value we use elsewhere in our application might by just the id of the object, "1" or "2".

I will outline three approaches we can use to structure our components, the first two are fully controlled components, and third is an uncontrolled approach.

Controlled vs uncontrolled components

You can read more about from the official documentation here.

For the purpose of this example, controlled components require their parent to manage the state of the component. Any user interaction with the component will require handling the components onChange handler and updating the state of the parent. If the onChange is not handled, there will be no apparent changes to the component when the user interacts with it. Uncontrolled components don't require the onChange handler be used - essentially the component can keep track of its own state.

Three Scenarios

For each of the approaches I'm going to discuss three different scenarios are handled with the approach.

Scenario 1 - On item selection, we need to immediately do something with the 'full object'.

eg. We select a a user from the list of users, and now we display the user's first name, last name, and address to the screen.

Scenario 2 - Populating the select from existing data, where we've referenced the item via an id:

eg. We this is likely an 'edit' form, and we have some intitial data that might look like this:

{
    assignee: "1", // Joe Bloggs

    // Other data here. 
}

And when we finish editing the data, we're saving a similar object.

Scenario 3 - Browser native form submission

We have a <form> and we are catching the form's submission event, and retreiving the values that way.

Approach A - Provide a list of items, determine which one is selected by some kind of 'value' key.

In this example and all following examples, we are going to implment a simple 'select' component.

Approach A has the following props:

export type SelectApproachAProps<T> = {

    label: string;
    name?: string;

    availableOptions: Array<T>;
    selectedOption: string | null;
    onChange: (value: string) => void;

    generateLabelFn: (value: T) => React.ReactNode;
    generateValueFn: (value: T) => string;

};

Let's talk through this one by one. Note there's a generic signature, we'll address that when we discuss availableOptions generateLabelFn and generateValueFn.

    label: string;

Pretty straight forward, just a label for the component.

     name?: string;

Again straight forward, this is just the name property that form elements have, which you can use on the form submission event to retrieve the value.

    availableOptions: Array<T>;

Here, what we're saying is that the available options are a list of any kind of item, so long as all of those items are the same shape. (eg. it could be [1,2,3] or it could be [{foo: "a"}, {foo: "b"}]).

    generateLabelFn: (value: T) => React.ReactNode;

generateLabelFn is a callback we use to generate the label for a given option. Note that return type is React.ReactNode - this means we can return complex JSX as the label if we want (say if we wanted to include user avatars when displaying a list of users for example), rather than just a simple string.

    generateValueFn: (value: T) => string;

generateValueFn is the callback we use to generate a string that represents the value of an item. This must be a string, and generally stands to to reason that each item in the list of available options would have a unique value.

    selectedOption: string | null;

We determine the state of which item is selected by passing that string value in. (Or null if something is selected).

    onChange: (value: string) => void;

When the user changes selection, the onChange fires with that string representation value of selected item.

The full implementation of this looks like this:


export const SelectApproachA = <T,>(props: SelectApproachAProps<T>) => {
    const {
        availableOptions,
        selectedOption,
        onChange,
        label,
        name,
        generateLabelFn,
        generateValueFn

    } = props;


    return (
        <label> {label}
            <select value={selectedOption || ""} onChange={(e) => {
                onChange(e.target.value);
            }} name={name}>

                <option value ="" disabled>(None Selected)</option>
                {availableOptions.map((v) => {
                    const optionLabel = generateLabelFn(v);
                    const optionValue = generateValueFn(v);

                    return <option key={optionValue} value={optionValue}> {optionLabel} </option>
                })}
            </select>
        </label>


    );
};

Pretty straight forward, we're making use of standard browser native features to attach value to the <option> element, and have that retreived in the <select> change handler.

One thing to note is that we treat the null selection as value "" and we've put in an option for that.

Approach A - Scenario 1

In the case where we immediately need to do something with the full item object, we need to look up the value from the original list. This adds a little more friction than is ideal.

export const ApproachAScenario1Demo = (props: {
        availableOptions: Array<{
            foo: string;
            bar: number
        }>;
    }) => {
    const { availableOptions } = props;
    const [selectedOption, setSelectedOption] = useState(null as null | string)

    // We need to look the item up from the list each time the selected item changes. 
    const fullSelectedOption = useMemo(() => {
        return availableOptions.find((v) => v.foo === selectedOption); // Or we could create a map first, which would be a bit more efficient
    }, [selectedOption, availableOptions]);

    return (
                <SelectApproachA 
                    availableOptions={availableOptions} 
                    selectedOption={selectedOption} 
                    onChange={setSelectedOption} 
                    label="Select Item" 
                    name="item"
                    generateLabelFn={(v) => v.foo}
                    generateValueFn={(v) => v.foo}
                />
    );
};

Approach A - Scenario 2

On the other hand, populating the selection state from some existing data is simple:

export const ApproachAScenario2Demo = (props: {
    availableOptions: Array<{
        foo: string;
        bar: number
    }>;

    existingForm: {
        selectedFoo: string;
    }
}) => {

    const [newForm, setNewForm] = useState(existingForm);
    return (
                <SelectApproachA 
                    availableOptions={availableOptions} 
                    selectedOption={newForm.selectedFoo} 
                    onChange={(newValue) => {
                        setNewForm({ ...newForm, selectedFoo: newValue });
                    }} 
                    label="Select Item" 
                    name="item"
                    generateLabelFn={(v) => v.foo}
                    generateValueFn={(v) => v.foo}
                />
    );
};

Approach A - Scneario 3

Capturing a form submission works, but we need to redundantly managed the selection state in the parent. ie. We're not doing anything with it in the parent, but need to include it, so that the component will respond to selection changes. This is the problem of using controlled components.

export const ApproachAScenario3Demo = (props: {
    availableOptions: Array<{
        foo: string;
        bar: number
    }>;
}) => {
    const { availableOptions } = props;


    const [selectedOption, setSelectedOption] = useState(null as null | string)

    return (
                <form onSubmit={(e) => {
                    e.preventDefault();

                    // I have to google this everytime I try to do this
                    // https://stackoverflow.com/questions/23427384/get-form-data-in-reactjs
                     
                    //@ts-ignore
                    const value = e.target["item"].value; 
                    alert(JSON.stringify({item:value}, null, 2)); 
                }}>
                    <SelectApproachA availableOptions={availableOptions} selectedOption={selectedOption} onChange={setSelectedOption} label="Select Item" name="item"
                        generateLabelFn={(v) => v.foo}
                        generateValueFn={(v) => v.foo}
                    />

                    <button type="submit">Submit Form</button>
                </form>

    );
};

As a side note here - note my @ts-ignore here. I would criticise React here - React seems to treat brower native form submission as a second class citizen, which is a shame because I think they're super helpful.

Approach B - Provide a list of items, provide the selected item

Approach B is very similar to approach A, we just change the how we tell the component which one is selected.

export type SelectApproachBProps<T> = {

    label: string;
    name: string;

    availableOptions: Array<T>;
    selectedOption: T | null; //<<< changed this line
    onChange: (value: T) => void;

    generateLabelFn: (value: T) => React.ReactNode;
    generateValueFn: (value: T) => string;

};

Instead of making selectedOption the string representation of a value, we make it the full object itself.

Full implementation:

export const SelectApproachB = <T,>(props: SelectApproachBProps<T>) => {
    const {
        availableOptions,
        selectedOption,
        onChange,
        label,
        name,
        generateLabelFn,
        generateValueFn

    } = props;


    // We need to retain a map to look up the 'full objects'  by their string representation. 
    const valueLookup = useMemo(() => {
        return availableOptions.reduce((acc, cur) => {
            const value = generateValueFn(cur);
            return {

                ...acc,
                [value]: cur
            };
        }, {} as Record<string, T>);
    }, [availableOptions, generateValueFn]);


    return (
        <label> {label}
            <select value={selectedOption ? generateValueFn(selectedOption) : ""} name={name} onChange={(e) => {
                const value = e.target.value; 
                const fullValue = valueLookup[value]; 
                onChange(fullValue);
            }}>


                <option value="" disabled>(None Selected)</option>
                {availableOptions.map((v) => {
                    const optionLabel = generateLabelFn(v);
                    const optionValue = generateValueFn(v);

                    // Because `<option>` elements only accept a string as their value, we still need to have a string representation of the values
                    return <option key={optionValue} value={optionValue}> {optionLabel} </option>
                })}
            </select>
        </label>


    );
};

Approach B - Scenario 1

This approach is improvement over Approach A Scenario 1 - we no longer need to look up the full object, we immediately have access to it.

export const ApproachBScenario1Demo = (props: ApproachADemoProps) => {
    const { availableOptions } = props;
    const [selectedOption, setSelectedOption] = useState(null as null | { foo: string; bar: number })

    return (
                <SelectApproachB 
                    availableOptions={availableOptions} 
                    selectedOption={selectedOption} 
                    onChange={setSelectedOption} 
                    label="Select Item" 
                    name="item"
                    generateLabelFn={(v) => v.foo}
                    generateValueFn={(v) => v.foo}
                />
    );
};

Approach B - Scenario 2

However, in the case of using some existing data that references id, we now need to find the full object from by searching through the list of objects for the one with the matching id.

export const ApproachBScenario2Demo = (props: {
    availableOptions: Array<{
        foo: string;
        bar: number
    }>;

    existingForm: {
        selectedFoo: string;
    }
}) => {
    const { availableOptions, existingForm } = props;


    const [newForm, setNewForm] = useState(existingForm);

    const selectedItem = useMemo(() => {
        return availableOptions.find((v) => {
            return v.foo === newForm.selectedFoo;
        }) || null;
    }, [newForm, availableOptions]);

    return (<SelectApproachB 
                availableOptions={availableOptions} 
                selectedOption={selectedItem} 
                onChange={(newValue) => {
                    setNewForm({ ...newForm, selectedFoo: newValue.foo });
                }} 
                label="Select Item" 
                name="item"
                generateLabelFn={(v) => v.foo}
                generateValueFn={(v) => v.foo}
                />
        );
};

So we can see that Approach A and B both have the same essential problem, depending on whether need to later reference an object by just its string value representation, or we need its full object.

Approach B - Scenario 3

Scneario 3 doesn't actually work for approach B, kind of. We can still capture the form submission. However, the value is always going to be a string. We can pass up full objects via a submission event.

Approach C - Uncontrolled component

In approach C we modify Approach B to be an uncontrolled component.

export type SelectApproachBProps<T> = {

    label: string;
    name: string;

    availableOptions: Array<T>;
    defaultSelectedOption: T | null; // Changed this defaultSelectedOption following the standard React convention
    onChange?: (value: T) => void; // We made this optional 

    generateLabelFn: (value: T) => React.ReactNode;
    generateValueFn: (value: T) => string;

};

Our implmentation does not change much:

the only difference is on this line:

            <select defaultValue={defaultSelectedOption ? generateValueFn(defaultSelectedOption) : ""} name={name} onChange={(e) => {

We change value to defaultValue.

Approach C - Scenario 3

Now we no longer need to manage the parent state in our 'form submission only' scenario

export const ApproachCScenario3Demo = (props: {
    availableOptions: Array<{
        foo: string;
        bar: number
    }>;
}) => {
    const { availableOptions } = props;

    return (
                <form onSubmit={(e) => {
                    e.preventDefault();

                    // I have to google this everytime I try to do this
                    // https://stackoverflow.com/questions/23427384/get-form-data-in-reactjs

                    //@ts-ignore
                    const value = e.target["item"].value;
                    alert(JSON.stringify({ item: value }, null, 2));
                }}>
                    <SelectApproachC 
                        availableOptions={availableOptions} 
                        defaultSelectedOption={null}  
                        label="Select Item" 
                        name="item"
                        generateLabelFn={(v) => v.foo}
                        generateValueFn={(v) => v.foo}
                    />

                    <button type="submit">Submit Form</button>
                </form>
    );
};

However, note that if trying to force state selection, you can have issues, the following code won't work:

export const ApproachCScenario4aDemo = (props: {
    availableOptions: Array<{
        foo: string;
        bar: number
    }>;
}) => {
    const { availableOptions } = props;

    const [selectedOption, setSelectedOption] = useState(availableOptions[0]); 

    return (
        <div className="App">
                <form onSubmit={(e) => {
                    e.preventDefault();

                    // I have to google this everytime I try to do this
                    // https://stackoverflow.com/questions/23427384/get-form-data-in-reactjs

                    //@ts-ignore
                    const value = e.target["item"].value;
                    alert(JSON.stringify({ item: value }, null, 2));
                }}>
                    <SelectApproachC 
                        availableOptions={availableOptions} 
                        defaultSelectedOption={selectedOption} 
                        onChange={setSelectedOption}  
                        label="Select Item" 
                        name="item"
                        generateLabelFn={(v) => v.foo}
                        generateValueFn={(v) => v.foo}
                    />

                    <button type="submit">Submit Form</button>
                </form>

                <pre>{JSON.stringify({selectedOption}, null, 2)}</pre>

                {/* We are trying to control the selection state after the component mounts  */}
                <button onClick = {() => {
                    setSelectedOption(availableOptions[2]); 
                }}>Force Selection State To Option C</button>


        </div>);
};

This can be solved by adding key ={selectedOption.foo} to our component, forcing React to treat it as an entirely new component when the selection state changes.