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!