I think the main issue here is that your current implementation replaces existing state, on each change, so it is impossible to know if/what changed. Not even a "usePrevious" hook to hold the previous state value would work here since it would only hold the last previous field edit, not the original value, for comparison. Many form handling solutions handle this by keeping an initial state that isn't mutated, or tracking "dirty" fields, and/or more.
Here's a suggestion of mine to tweak your form context state to track fields that have been updated. The dirtyFields
object holds the original value.
FormProvider
const [formValue, setFormValue] = useState({
basicDetails: {
fields: {
firstName: 'John',
lastName: '',
},
dirtyFields: {},
},
companyDetails: {
fields: {
companyName: 'Some Company',
designation: 'Some Designation',
},
dirtyFields: {},
},
});
BasicDetails
const BasicDetails = () => {
const [value, setValue] = React.useContext(FormContext);
const {
basicDetails: { fields }, // <-- get field values
} = value;
const handleInputChange = (event) => {
const { name, value } = event.target;
setValue((prev) => ({
...prev,
basicDetails: {
...prev.basicDetails,
fields: {
...prev.basicDetails.fields,
[name]: value,
},
...(prev.basicDetails.dirtyFields[name] // <-- only mark dirty once with original value
? {}
: {
dirtyFields: {
...prev.basicDetails.dirtyFields,
[name]: prev.basicDetails.fields[name],
},
}),
},
}));
};
return (
<>
<div className="form-group col-sm-6">
<label htmlFor="firstName">First Name</label>
<input
...
value={fields.firstName} // <-- access fields object
...
/>
</div>
<div className="form-group col-sm-4">
<label htmlFor="lastName">Last Name</label>
<input
...
value={fields.lastName}
...
/>
</div>
</>
);
};
EmploymentDetails
const EmploymentDetails = () => {
const [value, setValue] = React.useContext(FormContext);
const {
companyDetails: { fields },
} = value;
const handleInputChange = (event) => {
const { name, value } = event.target;
setValue((prev) => ({
...prev,
companyDetails: {
...prev.companyDetails,
fields: {
...prev.companyDetails.fields,
[name]: value,
},
...(prev.companyDetails.dirtyFields[name]
? {}
: {
dirtyFields: {
...prev.companyDetails.dirtyFields,
[name]: prev.companyDetails.fields[name],
},
}),
},
}));
};
return (
<>
<div className="form-group col-sm-6">
<label htmlFor="companyName">Company Name</label>
<input
...
value={fields.companyName}
...
/>
</div>
<div className="form-group col-sm-4">
<label htmlFor="designation">Designation</label>
<input
...
value={fields.designation}
...
/>
</div>
</>
);
};
Check dirty fields when incrementing/decrementing the step.
Give each section an id that matches the "form key" in the form context.
const sections = [
{
title: 'Basic Details',
id: 'basicDetails',
onClick: () => setCurrentPage(1),
},
{
title: 'Employment Details',
id: 'companyDetails',
onClick: () => setCurrentPage(2),
},
{ title: 'Review', id: 'review', onClick: () => setCurrentPage(3) },
];
Create a checkDirty
utility. Here I simply log the dirty fields
const checkDirty = (page) => {
console.log('check dirty', 'page', page);
console.log(
value[sections[page - 1].id] && value[sections[page - 1].id].dirtyFields,
);
};
const next = () => {
setCurrentPage((prev) => prev + 1);
checkDirty(currentPage); // <-- check for dirty fields when updating page step
};
const prev = () => {
setCurrentPage((prev) => prev - 1);
checkDirty(currentPage);
};
Because of the extra nested state in the form context, here's a utility to reduce it back to just form data you want rendered on the review step.
const prettyReview = sections.reduce(
(sections, section) => ({
...sections,
...(value[section.id]
? { [section.id]: { ...value[section.id].fields } }
: {}),
}),
{},
);
...
<pre>{JSON.stringify(prettyReview, null, 2)}</pre>
Edit
You said your data comes from a backend API call. Here's a context state initialization function that maps the API data shape to the state shape I have.
Given data from API
const apiData = {
basicDetails: {
firstName: 'John',
lastName: '',
},
companyDetails: {
companyName: 'Some Company',
designation: 'Some Designation',
},
};
Initialization function
const initializeContext = (data) =>
Object.entries(data).reduce(
(sections, [section, fields]) => ({
...sections,
[section]: {
fields,
dirtyFields: {},
},
}),
{},
);
Initialize the FormProvider
context state
function FormProvider({ children }) {
const [formValue, setFormValue] = useState(initializeContext(apiData));
return (
<FormContext.Provider value={[formValue, setFormValue]}>
{children}
</FormContext.Provider>
);
}