I'm working on planning app. The main page has a table with rows being projects, columns being weeks. Each cell is the workload of a person for the corresponding project and week. At the top of the table, there's a row that gives the total remaining availability for each week. It looks like this :
When the user types a workload in an input field, the value is automatically saved to the server after a short delay (debounce). Then a callback is called to trigger the update of the weekly totals at the top of the table.
Here's the hierarchy of components for this page:
WorkPlanPage
- ProjectWithWorkloads
- WorkloadInput
There are 2 issues with the code I'll show below :
- When the callback is called, the list of workloads held in
WorkPlanPage
is updated, which triggers the re-rendering of all children components. In doing so,WorkloadInput
s are all unmounted and remounted. If one was in the middle of being edited by a (very quick) user, the edit and associated call to the server would be lost (the debounce is cancelled). - I cannot prevent the re-rendering of all cells because the callback is updated every time a workload changes. So memoization won't work because the callback prop is updated at every workload update. This issue of parent-children relationship with callback seems very basic and yet I can't think of a way to avoid the re-rendering of all children, which could be a problem when there are a lot of children (50 projects, 15 weeks displayed, so 750 input fields.
Here's the code for the 3 components, with only relevant bits:
WorkPlanPage
const WorkPlanPage = () => {
// ...
const { data: leaves } = useQuery(
['leaves', { employeeId: employeeId, start: firstWeek, end: lastWeek }],
() => requestEmployeeLeaves(employeeId, firstWeek, lastWeek
{ refetchOnWindowFocus: false, initialData: [], enabled: (employeeId > 0) && (currentDate !== undefined) }
);
// this callback changes every time allWorkloads changes. The callback changes
// allWorkloads which in turn triggers the re-rendering of all the children.
const handleWorkloadChange = useCallback((updated: Workload) => {
if (allWorkloads === undefined) {
return;
}
// first remove the workload we're about to update
let newWorkloads = allWorkloads.filter(workload => {
return (
workload.project !== updated.project ||
workload.employee !== updated.employee ||
workload.date !== updated.date
);
});
newWorkloads.push(updated);
queryClient.setQueryData(
['workloads', { employeeId: employeeId, start: firstWeek, end: lastWeek }],
newWorkloads);
}, [allWorkloads, employeeId, firstWeek, lastWeek, queryClient]);
// ...
return (
<div>
<div>{/* Other parts of the page omitted */}</div>
<WeeklyAvailabilies
workloads={allWorkloads ? allWorkloads : []}
leaves={leaves ? leaves : []}
weeks={weeks} />
{displayedProjects?.map((project: Project) => (
<ProjectWithWorkload
key={project.id + "-" + employeeId}
employeeId={employeeId}
project={project}
weeks={weeks}
workloads={workloadsForProjects.get(project.id)}
onWorkloadChange={handleWorkloadChange}
/>
))}
</div>
</div>
);
}
ProjectWithWorkloads (rows)
const ProjectWithWorkload = ({
employeeId,
project,
weeks,
workloads,
onWorkloadChange
}) => {
return (
<div>
<div>{/* Removed project header */}</div>
<div>
{workloads.map((workload: Workload) => (
<div key={workload.project + workload.date}>
<WorkloadInput
project={project}
workload={workload}
onWorkloadChange={onWorkloadChanged}
/>
</div>
))}
</div>
</div>
);
}
WorkloadInput (cell)
const WorkloadInput = ({ project, workload, onWorkloadChange }) => {
const [effort, setEffort] = useState<string>("");
// ...
const handleEffortChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const valueAsString = event.currentTarget.value;
const valueAsNumber = parseFloat(valueAsString);
setEffort(event.currentTarget.value);
if (valueAsString === "") {
debouncedSaveWorkload(0);
} else if (!isNaN(valueAsNumber)) {
debouncedSaveWorkload(valueAsNumber);
}
};
const debouncedSaveWorkload = useMemo(() =>
_.debounce(saveWorkload, 500), [saveWorkload]
);
// cancel debounce on unmounting
useEffect(() => {
return () => {
debouncedSaveWorkload.cancel();
}
}, [debouncedSaveWorkload]);
const saveWorkload = useCallback((newEffort: number) => {
updateWorkload({
...workload, effort: newEffort
}).then((updatedWorkload) => {
onWorkloadChange(updatedWorkload); // where the callback is called
});
};
return (
<div>
<input
type="text"
value={workload}
onChange={handleEffortChange}
/>
</div>
);
};