0

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!

M. Çağlar TUFAN
  • 626
  • 1
  • 7
  • 21

3 Answers3

2

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.

bot15
  • 38
  • 4
  • Thanks for your advices. I'm going to implement React Redux to solve this prop mirroring. I think I will need to create seperate stores for seperate forms like login form, register form and so on. Then I will need to provide that created store for these forms. Am I correct? I also want to ask another question. I always like controlled inputs and reactivity on each key input, I don't like validation on form submission. Is there anything bad about it other than trade-off between performance and reactivity when using controlled inputs? – M. Çağlar TUFAN Jun 21 '23 at 13:08
  • 1
    No it's just one store, what's separate is reducers, you create multiple reducers, say 1 for each feature or each page and then use "combineReducers" from react-redux to merge all the reducers. You don't subscribe to entire store but parts of it, something like "state => state.login", "state => state.register" can be used to subscribe to login or register form's states. For your question, there's nothing wrong with each key validation as long as you're able to isolate this change to the granular component like input, problems may start when you propagate these changes to parent. – bot15 Jun 21 '23 at 15:11
  • Thanks a lot. I just set up Redux store to handle my login form. I created a general store and provided it to whole app, then created login form slice to manage overal form validity and each input's states inside that slice. It works quite fine right now and I don't experince any stutter/lag right now, maybe because I have 2 inputs there. I couldn't understand what you mean by "you don't subscribe to entire store but parts of it". I hope useSelector hook of react-redux ensures that. – M. Çağlar TUFAN Jun 21 '23 at 15:19
  • 1
    say you have a state: {root: {child1_l1: {child1_l2: {}, child2_l2: {}}, child2_l1: {}}. What I meant was what you might already be doing, you don't do "state => state" in your component. That will re-render your component any time state changes (if it's mounted), but you return a part of state like "state => state.child1_l1.child1_l2", now your component will only re-render whenever child1_l2 changes (doesn't care about changes in child2_l2 etc. useSelector enables us to "select" and subscribe to changes to a part of store, so yes it does ensure that. – bot15 Jun 21 '23 at 18:29
  • Alright, thanks again. Have a good day! – M. Çağlar TUFAN Jun 21 '23 at 18:39
1

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
Asplund
  • 2,254
  • 1
  • 8
  • 19
  • 1
    The uncontrolled version doesn't require to use refs if the form only contains form elements. The HTML `
    ` tag with a `sumbit` method is enough to get the values of everything. Here is [an example on the official docs](https://react.dev/reference/react-dom/components/textarea#reading-the-text-area-value-when-submitting-a-form).
    – Arkellys Jun 20 '23 at 17:34
  • @Undo thanks for sharing your answer. I really appreciate the knowledge you shared especially what other libraries can be used to achive same. I'm doing this as a challenge to myself since I'm bad at form handling and I don't want to include any 3rd party package to know what hardships may I face on the road. I tried to do the same functionality with uncontrolled inputs but as you mentioned, this results in loss of reactiveness. I'm not sure if I can do the same functionality like validation on each key stroke etc. with uncontrolled inputs. – M. Çağlar TUFAN Jun 20 '23 at 18:32
  • Additionally, with respect to your guidelines, #1; we can use some sort of state management to ensure one source of truth, this can be react context api I assume. And this will also help get rid of these mirrored props I think. I like to validate form and inputs on each key stroke and adding input focus states into that to provide better user experience, is there any other trade offs to that other than performance which comes with reactivity? Otherwise, there's no way to add this reactivity by checking forms on each for submission I think. – M. Çağlar TUFAN Jun 20 '23 at 18:35
  • I also wonder how can I benchmark different built styles of these components. Is there any tool that can help with that? Thanks again! – M. Çağlar TUFAN Jun 20 '23 at 18:36
  • @Arkellys you're right and I know about other ways of collecting form data, my broader point was more about imperatively collecting the data and less of how it's done but I'll add this collection method to my answer. – Asplund Jun 20 '23 at 19:00
0

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

roggc
  • 3
  • 4