React alternative way to prop drilling (reverse, from child to parents) for handling forms

694 Views Asked by At

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!

3

There are 3 best solutions below

4
On BEST ANSWER

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

  • Use redux if interacting components are more than 2 edges from each other
  • Use redux for sibling components i.e. child components that share a parent and need to talk to each other (select a tree item in side panel, display details of selected in main component)

As of your implementation

  • I find useInput an over-refactoring, your input component should be sufficient to manage operations related to input, better to abstract aspects like validations
  • You can either trigger all validations on form submit in which case you don't need to have a controlled input (attach validation on form's onSubmit event)
  • But if your form contains too many fields (say >5) and you want to validate the field before submission you can either use onBlur event of input field or use onInput along with a debounce operation like one from lodash or implement like this

function debounce(func, timeout = 300){
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => { func.apply(this, args); }, timeout);
  };
}

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

  • It's always a good idea to manage most of your state as low down in the component tree as possible in granular components
  • use onBlur event or a debounce function as shown above.
  • If the parent component does a lot of expensive rendering but INDEED needs data from child component for some operation like submit
    • use redux to dispatch data from child
    • create an onSubmit hook that aggregates form data (optionally does other operations - manage session, validate data) and returns a submit function and subscribe to child item's state change in onSubmit using useSelector
    • use onSubmit hook in parent and call submit function returned from this hook on clicking submit.

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.

5
On

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:

const [name, setName] = useState("");

return <input value={name} onChange={(e) => setName(e.currentTarget.value)} />

Edit: as @Arkellys pointed out you don't necessarily need refs to collect form data, here's an example using FormData

And uncontrolled:

const name = useRef(null);
const onSubmit = () => {
    const nameContent = name.current.value;
}
return <input ref={name} defaultValue="" />

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 problem react-hook-form and react-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:

  1. Keep one source of truth at the highest common ancestor
  2. Avoid mirroring and duplicating state
  3. Re-render as little as possible with useMemo and useRef
0
On

Use react-context-slices. It makes work with either Redux or React Context slices a breeze.