I’m new to React and learning it by making some practice projects. I’m currently working on form handling and validation. I use React Router’s Form component in my SPA and within the form I have my FormGroup element which renders labels inputs and error messages. I also use my own Input component inside the FormGroup component to seperate logic and state management of inputs used in form.
So the example Login page where I put my Form component and within FormGroup components look like this:
pages/Login.js
import { useState } from 'react';
import { Link, Form, useNavigate, useSubmit } from 'react-router-dom';
import FormGroup from '../components/UI/FormGroup';
import Button from '../components/UI/Button';
import Card from '../components/UI/Card';
import './Login.scss';
function LoginPage() {
const navigate = useNavigate();
const submit = useSubmit();
const [isLoginValid, setIsLoginValid] = useState(false);
const [isPasswordValid, setIsPasswordValid] = useState(false);
var resetLoginInput = null;
var resetPasswordInput = null;
let isFormValid = false;
if(isLoginValid && isPasswordValid) {
isFormValid = true;
}
function formSubmitHandler(event) {
event.preventDefault();
if(!isFormValid) {
return;
}
resetLoginInput();
resetPasswordInput();
submit(event.currentTarget);
}
function loginValidityChangeHandler(isValid) {
setIsLoginValid(isValid);
}
function passwordValidityChangeHandler(isValid) {
setIsPasswordValid(isValid);
}
function resetLoginInputHandler(reset) {
resetLoginInput = reset;
}
function resetPasswordInputHandler(reset) {
resetPasswordInput = reset;
}
function switchToSignupHandler() {
navigate('/signup');
}
return (
<div className="login">
<div className="login__logo">
Go Cup
</div>
<p className="login__description">
Log in to your Go Cup account
</p>
<Card border>
<Form onSubmit={formSubmitHandler}>
<FormGroup
id="login"
label="User name or e-mail address"
inputProps={{
type: "text",
name: "login",
validity: (value) => {
value = value.trim();
if(!value) {
return [false, 'Username or e-mail address is required.']
} else if(value.length < 3 || value.length > 30) {
return [false, 'Username or e-mail address must have at least 3 and at maximum 30 characters'];
} else {
return [true, null];
}
},
onValidityChange: loginValidityChangeHandler,
onReset: resetLoginInputHandler
}}
/>
<FormGroup
id="password"
label="Password"
sideLabelElement={
<Link to="/password-reset">
Forgot password?
</Link>
}
inputProps={{
type: "password",
name: "password",
validity: (value) => {
value = value.trim();
if(!value) {
return [false, 'Password is required.']
} else if(value.length < 4 || value.length > 1024) {
return [false, 'Password must be at least 4 or at maximum 1024 characters long.'];
} else {
return [true, null];
}
},
onValidityChange: passwordValidityChangeHandler,
onReset: resetPasswordInputHandler
}}
/>
<div className="text-center">
<Button className="w-100" type="submit">
Log in
</Button>
<span className="login__or">
or
</span>
<Button className="w-100" onClick={switchToSignupHandler}>
Sign up
</Button>
</div>
</Form>
</Card>
</div>
);
}
export default LoginPage;
As you can see in above code, I use FormGroup components and pass onValidityChange
and onReset
properties to get isValid
value’s updated value when it changes and reset
function to reset the input after form submission etc. isValid
and reset
functions are created in Input component using my custom hook, useInput. I pass isValid
value when it changes and reset
function from Input component using props defined in FormGroup component. I also use isLoginValid
and isPasswordValid
states defiend in Login page to store the updated isValid
state values passed from children Input components. So I already have states defiend in Input component and pass them to parent components using props and store their valeus in other states created in that parent component. There’s a prop drilling in action and makes me feel a bit uncomfortable.
States are managed inside Input component and there I have these states:
- value: Value of the input element.
- isInputTouched: To determine if user has touched/focused input to determien whether or not to show validation error message (if there is).
I combine and apply some functions (e.g. validation function passed to Input component) to these two states to create other variable values to gather information about the input and their validities like if the value is valid (isValid), if there is message of validation (message), if input is valid (isInputValid = isValid || !isInputTouched
) to decide on showing the validation message.
These states and values are managed in custom hook I created, useInput
as below:
hooks/use-state.js
import { useState, useCallback } from 'react';
function useInput(validityFn) {
const [value, setValue] = useState('');
const [isInputTouched, setIsInputTouched] = useState(false);
const [isValid, message] = typeof validityFn === 'function' ? validityFn(value) : [true, null];
const isInputValid = isValid || !isInputTouched;
const inputChangeHandler = useCallback(event => {
setValue(event.target.value);
if(!isInputTouched) {
setIsInputTouched(true);
}
}, [isInputTouched]);
const inputBlurHandler = useCallback(() => {
setIsInputTouched(true);
}, []);
const reset = useCallback(() => {
setValue('');
setIsInputTouched(false);
}, []);
return {
value,
isValid,
isInputValid,
message,
inputChangeHandler,
inputBlurHandler,
reset
};
}
export default useInput;
I currently use this custom hook in Input.js like this:
components/UI/Input.js
import { useEffect } from 'react';
import useInput from '../../hooks/use-input';
import './Input.scss';
function Input(props) {
const {
value,
isValid,
isInputValid,
message,
inputChangeHandler,
inputBlurHandler,
reset
} = useInput(props.validity);
const {
onIsInputValidOrMessageChange,
onValidityChange,
onReset
} = props;
let className = 'form-control';
if(!isInputValid) {
className = `${className} form-control--invalid`;
}
if(props.className) {
className = `${className} ${props.className}`;
}
useEffect(() => {
if(onIsInputValidOrMessageChange && typeof onIsInputValidOrMessageChange === 'function') {
onIsInputValidOrMessageChange(isInputValid, message);
}
}, [onIsInputValidOrMessageChange, isInputValid, message]);
useEffect(() => {
if(onValidityChange && typeof onValidityChange === 'function') {
onValidityChange(isValid);
}
}, [onValidityChange, isValid]);
useEffect(() => {
if(onReset && typeof onReset === 'function') {
onReset(reset);
}
}, [onReset, reset]);
return (
<input
{...props}
className={className}
value={value}
onChange={inputChangeHandler}
onBlur={inputBlurHandler}
/>
);
}
export default Input;
In Input component I use isInputValid
state directly to add invalid CSS class to input. But I also pass isInputValid
, message
, isValid
states and reset
function to parent components to use in them. To pass these states and function, I use onIsInputValidOrMessageChange
, onValidityChange
, onReset
functions that are defined in props (prop drilling but in reverse direction, from children to parents).
Here’s FormGroup component’s definition and how I use Input’s states inside FormGroup to show validation message (if there is):
components/UI/FormGroup.js
import { useState } from 'react';
import Input from './Input';
import './FormGroup.scss';
function FormGroup(props) {
const [message, setMessage] = useState(null);
const [isInputValid, setIsInputValid] = useState(false);
let className = 'form-group';
if(props.className) {
className = `form-group ${props.className}`;
}
let labelCmp = (
<label htmlFor={props.id}>
{props.label}
</label>
);
if(props.sideLabelElement) {
labelCmp = (
<div className="form-label-group">
{labelCmp}
{props.sideLabelElement}
</div>
);
}
function isInputValidOrMessageChangeHandler(changedIsInputValid, changedMessage) {
setIsInputValid(changedIsInputValid);
setMessage(changedMessage);
}
return (
<div className={className}>
{labelCmp}
<Input
id={props.id}
onIsInputValidOrMessageChange={isInputValidOrMessageChangeHandler}
{...props.inputProps}
/>
{!isInputValid && <p>{message}</p>}
</div>
);
}
export default FormGroup;
As you can see from the above code, I define message
and isInputValid
states to store updated message
and isInputValid
states passed from Input component. I already have 2 states defined in Input component to hold these values, yet I need to define another 2 state in this component to store their updated and passed values from Input component. This is kind of weird and doesn’t seem best way to me.
Here’s the question: I think I can use React Context (useContext) or React Redux to solve this prop drilling problem here. But Im not sure if my current state management is bad and could be better with React Context or React Redux. Because from what I’ve learned, React Context can be bad in case of frequently changing states but that’s valid if the Context is used in app-wide scale. Here I can possible create a Context just to store and update whole form, so form-wide scale. On the other hand, React Redux may not be best fitting solituon and can be a bit overkill. What do you guys think? What might be a better alternative to this specific situation?
Note: Since I’m a newbie to React, I’m open to all your advices regarding all of my codings, from simple mistakes to general mistakes. Thanks!
2
Answers
There are two main schools of thought regarding React form state management: controlled and uncontrolled. A controlled form would likely be controlled using a React context, wherein values can be accessed anywhere to provide reactivity. However, controlled inputs can cause performance issues, especially when an entire form is updated on each input. That’s where uncontrolled forms come in. With this paradigm, all state management is done imperatively utilizing the browser’s native capabilities to display the state. The main issue with this method is that you lose the React aspect of the form, you need to manually collect the form data on submission and it can be tedious to maintain several refs for this.
A controlled input looks like this:
Edit: as @Arkellys pointed out you don’t necessarily need refs to collect form data, here’s an example using
FormData
And uncontrolled:
As apparent in these two examples, maintaining multi component forms using either method is tedious therefore, it’s common to use a library to help you manage your form. I would personally recommend React Hook Form as a battle tested, well maintained and easy-to-use form library. It embraces the uncontrolled form for optimal performance while still allowing you to watch individual inputs for reactive rendering.
With regard to whether to use Redux, React context or any other state management system, it generally makes little difference with respect to performance, assuming you implement it correctly. If you like the flux architecture then by all means use Redux, for most cases however, React context is both performant and sufficient.
Your
useInput
custom hooks looks to be a valiant, yet misguided attempt at solving the problemreact-hook-form
andreact-final-form
have already solved. You’re creating unnecessary complexity and unpredictable side-effects with this abstracting. Additionally, you are mirroring props which is generally an anti-pattern in React.If you truly want to implement your own form logic which I advise against unless it’s for educational purposes you can follow these guidelines:
useMemo
anduseRef
Here’s a direct aspect that I use to decide between pub-sub libraries like redux and propagating state through component tree.
Propagate the child state to parent if two components have a parent-child relationship and are maximum two edges away from each other
Parent -> child1-level1 -> child1-level2 —— GOOD
Parent -> child1-level1 —— GOOD
Parent -> child1-level1 -> child1-level2 -> child1-level3 –> too much travel to put state change from child1-level3 to parent
As of your implementation
where "func" is the onChange or onInput handler
This will prevent calls on each key press. But then again all validation related operations can be managed within Input component. You can display a generic Error component underneath your input or any other form component. The only other scenario I see where you might need form data in parent before submission time is when two form fields are dependent on each other like password and confirm password or cities drop down populating after selecting a state.
Remember that any change that you propagate from a child component to parent will re-render the parent along with all the other child components. This can easily lead to an observable stutter or lag during typing as your parent component grows.
Few ideas to prevent that
That way your form data related operations are abstracted and isolated
Though you’re probably not looking for it but may I also suggest using library like Formik for all operations related to form with an additional benefit of keeping all form related configurations in a single place that can be a json file or can even be fetched from a backend server. This is also a good first step to experiment with no-code development paradigm.