Best practice for validating input while onChange inside React custom hook? - reactjs

I built a custom hook to handle a form. One thing I'm having trouble with is calling the validation while the input value is changing.
I have four code snippets included. The second and third are just for context to show how the complete custom hook but feel free to skip them as I'm just curious about how to implement similar functionality from snippet 1 in snippet 4.
The reason I want to do this, in addition to calling it on submit, is that if the input value becomes ' ' I would like to display the error message and when a user started typing it would go away.
This was pretty simple when I wasn't using hooks I would just call a validate function after setState like this:
const validate = (name) => {
switch(name):
case "username":
if(!values.username) {
errors.username = "What's your username?";
}
break;
default:
if(!values.username) {
errors.username = "What's your username?";
}
if(!values.password) {
errors.username = "What's your password?";
}
break;
}
const handleChange = (e) => {
let { name, value } = e.target;
this.setState({ ...values,
[name]: value
}, () => this.validate(name))
}
So now using react hooks things are not as easy. I created a custom form handler that returns values, errors, handleChange, and handleSubmit. The form handler is passed an initialState, validate function, and a callback. As of now it looks like this:
import useForm from './form.handler.js';
import validate from './form.validation.js';
const schema = { username: "", password: "" }
export default function Form() {
const { values, errors, handleChange, handleSubmit } = useForm(schema, validate, submit);
function submit() {
console.log('submit:', values);
}
return (
<form></form> // form stuff
)
}
Here's the validation file. It's simple, it just requires values for two fields.
export default function validate(values) {
let errors = {};
if(!values.username) {
errors.username = "What's your username?";
}
if(!values.password) {
errors.password = "What's your password?";
}
return errors;
}
Now here is my form handler, where I'm trying to solve this problem. I have been trying different things around calling setErrors(validate(values)) in the useEffect but can't access the input. I'm not sure, but currently, the custom hook looks like this:
import { useState, useEffect, useCallback } from 'react';
export default function useForm(schema, validate, callback) {
const [values, setValues] = useState(schema),
[errors, setErrors] = useState({}),
[loading, setLoading] = useState(false); // true when form is submitting
useEffect(() => {
if(Object.keys(errors).length === 0 && loading) {
callback();
}
setLoading(false);
}, [errors, loading, callback])
// I see useCallback used for event handler's. Not part of my questions, but is it to prevent possible memory leak?
const handleChange = (e) => {
let { name, value } = e.target;
setValues({ ...values, [name]: value });
}
const handleSubmit = (e) => {
e.preventDefault();
setLoading(true);
setErrors(validate(values));
}
return { values, errors, handleChange, handleSubmit }
}

I'm not sure if it's a good idea to set other state (errors) while in a callback to set state (values) so created a code review
As commented; you can set errors while setting values:
const Component = () => {
const [values, setValues] = useState({});
const [errors, setErrors] = useState({});
const onChange = useCallback(
(name, value) =>
setValues((values) => {
const newValues = { ...values, [name]: value };
setErrors(validate(newValues));//set other state while in a callback
return newValues;
}),
[]
);
return <jsx />;
};
Or combine values and errors:
const Component = () => {
const [form, setForm] = useState({
values: {},
errors: {},
});
const onChange = useCallback(
(name, value) =>
setForm((form) => {
const values = { ...form.values, [name]: value };
const errors = validate(values);
return { values, errors };
}),
[]
);
const { errors, values } = form;
return <jsx />;
};

Related

Input text causing unnecessary button rendering

I need to check the fields and i need to pass several properties to the button. String, Object, Array, Func and i need usestate be an object like example. Any solution to not cause re-rendering?
EDIT: I need to do a way that when the user fills in the input the button does not re-render.
EDIT 2: when I use the primitive type useState(string). And I make the comparison works , useState of type Object making comparison causes re-rendering on button:
code below does not cause re-rendering
const [email, setEmail] = useState("");
const validateFields = useCallback(() => {
if (email === "") {
alert("Empty e-mail.");
return false;
}
return true;
}, [email]);
code below does CAUSE re-rendering - (comparison with object)
const [userData, setUserData] = useState({
email: "",
password: ""
});
const validateFields = useCallback(() => {
if (userData.email === "") {
alert("Empty e-mail.");
return false;
}
return true;
}, [userData]);
project link : https://codesandbox.io/s/tender-allen-tfklv8?file=/src/components/Button.js
import { useCallback, useState } from "react";
import Button from "./components/Button";
import "./styles.css";
export default function App() {
const [userData, setUserData] = useState({
email: "",
password: ""
});
const validateFields = useCallback(() => {
if (userData.email.trim() === "") {
alert("Empty e-mail.");
return false;
}
return true;
}, [userData]);
const handleAPI = useCallback(() => {
if (validateFields()) {
console.log("handleAPI");
}
}, [validateFields]);
return (
<div className="App">
<input
value={userData.email}
onChange={(event) =>
setUserData((prev) => ({
...prev,
email: event.target.value
}))
}
/>
<Button onClick={handleAPI} />
</div>
);
}
const Button = ({ onClick }) => {
console.log("------------Button render---------------");
return <button onClick={onClick}>BUTTON DEFAULT</button>;
};
export default memo(Button);
If you're intending to use the validateData function when clicking on the button, there's no need for the handleAPI function to be within a useCallback hook that depends on validateData. Whenever you click on the button, the validateData function will be called and will validate the desired data with whatever value is currently in the userData state variable.

React component does not re-render when I use a component for building forms

So I have this component which is used for forms handling:
import React, { useState } from "react";
export const useForm = (callback: any, initialState = {}) => {
const [values, setValues] = useState(initialState);
const onChange = (event: React.ChangeEvent<any>) => {
setValues({ ...values, [event.target.name]: event.target.value });
};
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await callback();
};
return {
onChange,
onSubmit,
setValues,
values,
};
}
in my component, I use it like this:
const { id } = useParams();
const initialState = {
field1: "",
field2: "",
....
}
const { onChange, onSubmit, setValues, values } = useForm(
submitData,
initialState
);
useEffect(
() => {
if(id) {
getData().then((response) => {
setValues(response);
console.log(response);
console.log(values);
});
}
}
,[]);
async function getData() {
return await (await instance.get(url)).data;
}
The two console.logs in the useEffect hook are different and I don't understand why.
I used setValues so I expect the components to be re-drawn and the "values" variable to be updated with the values from the response, but it still has the values from the initialState for some reason.
Why is that happening and how can I fix it?
The change of state using setState hook will not be reflected synchronously.
This is taken from react docs:
setState() does not always immediately update the component. It may batch or defer the update until later. This makes reading this.state right after calling setState() a potential pitfall. Instead, use componentDidUpdate or a setState callback (setState(updater, callback)), either of which are guaranteed to fire after the update has been applied.

React Hooks Form - onSubmit with data from useState

Hi :) I want to create checkout form with customer's address data, but onSubmit I would like to attach order data stored in useState (cartItems). For now, I've finished react hook form with onSubmit button that console.log only form inputs.
What is the best way to do that?
const Checkout = ({ emoneyPayment, setEmoneyPayment, cartItems }: CheckoutProps) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Order>();
I'm unfamiliar with React Hooks Form and TypeScript, but in JS / React you could wrap it in another hook:
const Checkout = ({ emoneyPayment, setEmoneyPayment, cartItems }: CheckoutProps) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Order>();
const [cartItems, setCartItems] = useState([])
// this function will be redefined every time cartItems or handleSubmit updates
const submitForm = useCallback(() => {
handleSubmit(cartItems)
}, [cartItems, handleSubmit])
return <div onClick={submitForm}>Submit</div>
}
UPDATE:
You will also need to pass in the cartItems to the handleSubmit. This seems to be the way to do so:
const Checkout = ({ emoneyPayment, setEmoneyPayment, cartItems }: CheckoutProps) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Order>();
const [cartItems, setCartItems] = useState([])
const onSubmit = (data) => {
const finalData = {...data, cartItems}
alert(JSON.stringify(finalData));
// do your actual submit stuff here
};
return <form onSubmit={handleSubmit(onSubmit)}>...formcomponents...</form>
}

Using history.block with asynchronous functions/callback/async/await

I have a form inside a route, that if there are any validation errors, it should not allow the user to navigate to another route. If there are no validation errors, then allow navigation to another route.
Below is my current code, which the onBlock function does not work does to its async nature as the functions to submit and then validate the form are asynchronous.
FormComponent.js
import React, { useState, useEffect, useRef } from "react";
import { FieldArray } from "formik";
import { useHistory } from "react-router-dom";
import * as Yup from "yup";
import Form from "./Form";
import TextInput from "./TextInput";
const FormComponent = () => {
const history = useHistory();
const [initialValues, setInitialValues] = useState();
const [isSubmitted, setIsSubmitted] = useState(false);
const block = useRef();
const formRef = useRef(null);
const onFormSubmit = async (values) => {
setIsSubmitted(true);
};
const validationSchema = () => {
const schema = {
test: Yup.string().required("Input is Required")
};
return Yup.object(schema);
};
const onBlock = () => {
const { submitForm, validateForm } = formRef?.current || {};
// submit form first
submitForm()
.then(() => {
// then validate form
validateForm()
.then(() => {
// if form is valid - should navigate to route
// if form is not valid - should remain on current route
return formRef?.current.isValid;
})
.catch(() => false);
})
.catch(() => false);
};
const redirectToPage = () => {
history.push("/other-page");
};
useEffect(() => {
block.current = history.block(onBlock);
return () => {
block.current && block.current();
};
});
useEffect(() => {
if (isSubmitted) redirectToPage();
}, [isSubmitted]);
useEffect(() => {
setInitialValues({
test: ""
});
}, []);
return initialValues ? (
<Form
initialValues={initialValues}
onSubmit={onFormSubmit}
formRef={formRef}
validationSchema={validationSchema}
>
<FieldArray
name="formDetails"
render={(arrayHelpers) =>
arrayHelpers && arrayHelpers.form && arrayHelpers.form.values
? (() => {
const { form } = arrayHelpers;
formRef.current = form;
return (
<>
<TextInput name="test" />
<button type="submit">Submit</button>
</>
);
})()
: null
}
/>
</Form>
) : null;
};
export default FormComponent;
If a user tries to submit the form without any value in the input, I would expect that onBlock would return false to block navigation. But this does not seem to work. Simply returning false in the onBlock function does however. So it seems that the history.block function does not accept any callbacks. I have also tried to convert it to an async function and await the submitForm & validateForm functions, but still no joy. Is there a way around this? Any help would be greatly appreciated.
Here is CodeSandbox with an example.
The history.block function accepts a prompt callback which you can use to prompt the user or do something else in response to the page being blocked. To block the page you just need to call history.block() more info here.
The formik form is validated when you try to submit it and if it successfully validates then it proceeds to submit the form, this is when onSubmit callback will be called. So if you'd like to block the page when there are validation errors you can use the formik context to subscribe to the validation isValid and whenever that is false block.
const useIsValidBlockedPage = () => {
const history = useHistory();
const { isValid } = useFormikContext();
useEffect(() => {
const unblock = history.block(({ pathname }) => {
// if is valid we can allow the navigation
if (isValid) {
// we can now unblock
unblock();
// proceed with the blocked navigation
history.push(pathname);
}
// prevent navigation
return false;
});
// just in case theres an unmount we can unblock if it exists
return unblock;
}, [isValid, history]);
};
Here is a codesandbox for that adapted from yours. I removed some components that weren't needed.
Another solution is validating manually on all page transitions and choosing when to allow the transition yourself and in this case it is if validateForm returns no errors.
// blocks page transitions if the form is not valid
const useFormBlockedPage = () => {
const history = useHistory();
const { validateForm } = useFormikContext();
useEffect(() => {
const unblock = history.block(({ pathname }) => {
// check if the form is valid
validateForm().then((errors) => {
// if there are no errors this form is valid
if (Object.keys(errors).length === 0) {
// Unblock the navigation.
unblock();
// retry the pagination
history.push(pathname);
}
});
// prevent navigation
return false;
});
return unblock;
}, [history, validateForm]);
};
And the codesandbox for that here

Update user profile information page - React useState

I am trying to make an update user page with previous information to be rendered inside the input fields. Console.log returns the correct value but its not showing up as the initial value inside of the useState.
Getting previous user bio
function EditProfile(props) {
const user = useSelector(state => state.user);
const [profile, setProfile] = useState([])
const userId = props.match.params.userId
const userVariable = {
userId: userId
}
useEffect(() => {
axios.post('/api/users/getProfile', userVariable)
.then(response => {
if (response.data.success) {
console.log(response.data)
setProfile(response.data.user)
} else {
alert('Failed to get user info')
}
})
}, [])
console.log(profile.bio);
Heres what I am currently using to display the input field. (edited for brevity)
const [bio, setBio] = useState("");
const handleChangeBio = (event) => {
console.log(event.currentTarget.value);
setBio(event.currentTarget.value);
}
return (
<label>Bio</label>
<TextArea
id="bio"
onChange={handleChangeBio}
value={bio}
/>
)
Was trying to do this before but object was not showing up as the useState initial value
const [bio, setBio] = useState(User.bio);
Back-end - I know that $set overrides all information, so was trying to render the previous information inside of the input fields so it would not be overrided with blank values.
router.post('/edit', auth, (req, res)=> {
console.log(req.body.education)
User.updateMany(
{ _id: req.user._id },
[ {$set: { bio: req.body.bio}},
{$set: { industry: req.body.industry}},
{$set: { jobTitle: req.body.jobTitle}},
],
(err)=>{
if (err) return res.json({success: false, err});
return res.status(200).send({
success: true
});
});
});
Create some custom component and put User as props and you will see that you get data.
const [User, setUser] = useState([])
better to change to
const [user, setUser] = useState('')
You can get some issues because components starts with capital letter
And array as default value may error after first render
You can move it to separate component:
<Example user={user} />
const Example = (props) => {
const [bio, setBio] = useState(props.user.bio);
const handleChangeBio = (event) => {
console.log(event.currentTarget.value);
setBio(event.currentTarget.value);
}
return (
<label>Bio</label>
<TextArea
id="bio"
onChange={handleChangeBio}
value={bio}
/>
)
}

Resources