Handling complex form state using React hooks

An alternative to using this.setState()

Aditya Loshali
Level Up Coding

--

is every body hooked yet ? Image Credits — Free Code Camp

It’s been a while since React hooks were released, and everyone has fallen in love with them. I understand because I’m one of you too. Hooks got me hooked!

Hooks allow us to create smaller, composable, reusable more manageable React components.

One use case you may be using hooks for is to manage form state using useState or useReducer.

Let’s consider a scenario where you have to manage a complex form state with multiple form inputs, which can be several different types like text, number, date input. The form state may even have nested information such as a user’s address information which has its own sub-fields like address.addressLine1, address.addressLine2, etc.

Maybe you also have to update form state based on the current state, such as a toggle button.

Now, if you are using useState for each individual form field, then you have the ability to compute new state based on the current state.

const [modalActive, updateModal] = useState(false)
.
.
.
// new state based on previous
updateModal(prev => !prev)

But, if you have too many individual form fields, like 100+ (YESS!! I was managing 100+ form fields) then this approach isn’t friendly.

Imagine...

const [firstName, setFirstName] = useState('')
const [middleName, setMiddleName] = useState('')
const [lastName, setLastName] = useState('')
.
.
.
.

It isn’t practical to write separate useStates and then use separate update functions for each field. Our other option would be the Hook, useReducer.

Let’s look at an example.

const initialState = {
firstName: '',
lastName: ''
};
function reducer(state, action) {
switch (action.type) {
case 'firstName':
return { firstName: action.payload };
case 'lastName':
return { lastName: action.payload };
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
<input
type="text"
name="firstName"
placeholder="First Name"
onChange={(event) => {
dispatch({
type: 'firstName',
payload: event.target.value
})
}}
value={state.firstName} />
<input
type="text"
name="lastName"
placeholder="Last Name"
onChange={(event) => {
dispatch({
type: 'lastName',
payload: event.target.value
})
}}
value={state.lastName} />
</>
);
}

Eh, not good. You cannot possibly write each use case for those n number of form fields in the reducer.

However, the reducer function used in useReducer is just a normal function that returns an updated state object. So, we can make it better.

function reducer(state, action) {
// field name and value are retrieved from event.target
const { name, value } = action

// merge the old and new state
return { ...state, [name]: value }
}

Now this looks like a better and cleaner reducer.

However, this does not allow us to compute new state based on the current state while calling update function with a callback, like we can do with useState:

this.setState((prev) => ({ isActive: !prev }))// orconst [modalActive, updateModal] = useState(false)
.
.
.
updateModal(prev => !prev)

Also, what about updating nested state like address.addressLine1, address.pinCode.

We have had a lot of discussion regarding managing complex form state by using not so ideal approaches. Let me just show you the solution.

ta da!!

So, here is the full source code for handling complex form scenarios.

I’ll explain the reducer (enhancedReducer :P) function a little.

The reducer function receives two arguments, the first argument is the current state before the update. This argument is automatically provided when you call the updateState / dispatch function to update the reducer state. The second argument of the reducer function is the value you call the updateState function with. It need not be the typical redux action object taking the form { type: ‘something’, payload: ‘something’ }. It can be anything, a number, string, object or a function even.

And this is what we are utilizing. If the updateArg is a function, we call it with the current state to calculate the new one. Whatever object we return from this function becomes our new state.

And if the updateArg is a plain old Javascript object, then there are two cases.

1: The object does not have the _path and _value properties and thus is a normal update object just like we give to this.setState. So, you can just call updateState with a new object with the pieces of the state that you want to update, and it will merge it with old one and return the new state.

2: The object has the _path and _value properties — when the updateState function is called with an object with these two properties. we treat this a special case where _path represents a nested field path. In either a string form eg: 'address.pinCode' or an array representing the path [‘address’, ‘pinCode’].

What do we do with such path representations to update a nested field in an object? We will use lodash’s set method. It accepts both the path forms as valid inputs to update and object.

set(objectToUpdate, path, newValue)const state = {
name: {
first: '',
middle: '',
last: ''
}
}
// and to update, for eg: first name.
// both ways of path are correct.
set(state, 'name.first', 'Aditya')
set(state, ['name', 'first'], 'Aditya')

However, the set method mutates the object in-place and does not return a new copy, but in the React world, change detection depends on Immutability. A fresh new copy of data, with a new location in memory is needed to trigger a render.

To bypass this we use immer, which helps with handling immutability with Javascript objects in a easy to use form.

import produce from 'immer'produce(state, draft => {
set(draft, _path, _value);
});

The produce function from immer takes the object to work on as its first argument which in our case is the current state, and its second argument is a function which receives a draft copy of the object to mutate, whatever you modify inside of this function on the draft state, is done on the copy, not the actual input object state in-place. Then, it automatically returns the new object with updated data.

So, there’s our enhanced reducer :D

Just

yarn add lodash immer

and enjoy.

PS: The example in the gist can be refined much further, with more edge cases handled in enhancedReducer and the form fields code can be shortened by mapping over the form spec object to create it dynamically and reduce code duplication and some other things too.

Some of you may be wondering that if we are so trying to replicate this.setState, then why not have the setState second argument callback function too which performs some action after the state has been updated. Well, that’s not declarative enough! We will be telling the code, step by step, how to do something. Instead of simply telling it what to do. I would use useEffect instead of a callback function because that’s declarative and reacts to changes.

Declarative vs imperative and functional programming is a whole other talk that I’ll share in the future.

Resources:

--

--

I’m a full stack web developer working with Javascript on React and Node.js everyday.