T
TanStack9mo ago
fair-rose

React Query with dnd

Hello. I’m building a Kanban board with drag-and-drop functionality using react-query and @hello-pangea/dnd (formerly react-beautiful-dnd). The Kanban data is directly displayed from the data managed by a query, and I’ve implemented optimistic updates using onMutate and setQueryData as shown in the code below. The problem is that when I change the order of the Kanban data via drag-and-drop, the data briefly shows the state before the change and then updates to the new order, causing a flicker in the component. This momentary flicker negatively affects the user experience. As far as I can tell, there’s nothing in my setQueryData code that violates data immutability. Is there something I might be missing?
7 Replies
fair-rose
fair-roseOP9mo ago
// Board.tsx
const handleDragEnd = ({ draggableId, type, source, destination }: DropResult) => {
document.body.style.cursor = 'default';
if (!destination || (source.droppableId === destination.droppableId && source.index === destination.index)) {
return;
}

const itemId = Number(draggableId.split('_')[1]);
let newOrder = destination.index;

if (type === 'section_droppable') {
const updateInfo = { order: newOrder };
const params = { spaceId, boardId, sectionId: itemId, updateInfo };
updateSection(params);
}
};

return (
<DragDropContext onDragStart={handleOnDragStart} onDragEnd={handleDragEnd}>
<>
<div className="w-full" id="section">
<SectionList />
</div>
</>
</DragDropContext>
)

// SecionList.tsx
const { data: sections = [] } = useQuery({
...boardQueries.board({ spaceId, boardId }),
select: state => state.sections.sort((a, b) => a.order - b.order),
});

return (
<Droppable droppableId="board" direction="horizontal" type="section_droppable">
{provided => (
<div
ref={provided.innerRef}
className="h-[calc(100vh - 212px)] flex justify-start px-6"
style={{ width: sections.length * SECTION_WIDTH }}
{...provided.droppableProps}
>
{sections.map((section, index) => (
<Section key={section.sectionId} section={section} index={index} />
))}
</div>
)}
</Droppable>
);
// Board.tsx
const handleDragEnd = ({ draggableId, type, source, destination }: DropResult) => {
document.body.style.cursor = 'default';
if (!destination || (source.droppableId === destination.droppableId && source.index === destination.index)) {
return;
}

const itemId = Number(draggableId.split('_')[1]);
let newOrder = destination.index;

if (type === 'section_droppable') {
const updateInfo = { order: newOrder };
const params = { spaceId, boardId, sectionId: itemId, updateInfo };
updateSection(params);
}
};

return (
<DragDropContext onDragStart={handleOnDragStart} onDragEnd={handleDragEnd}>
<>
<div className="w-full" id="section">
<SectionList />
</div>
</>
</DragDropContext>
)

// SecionList.tsx
const { data: sections = [] } = useQuery({
...boardQueries.board({ spaceId, boardId }),
select: state => state.sections.sort((a, b) => a.order - b.order),
});

return (
<Droppable droppableId="board" direction="horizontal" type="section_droppable">
{provided => (
<div
ref={provided.innerRef}
className="h-[calc(100vh - 212px)] flex justify-start px-6"
style={{ width: sections.length * SECTION_WIDTH }}
{...provided.droppableProps}
>
{sections.map((section, index) => (
<Section key={section.sectionId} section={section} index={index} />
))}
</div>
)}
</Droppable>
);
// mutate
export const useUpdateSection = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (params: Parameters<typeof updateSection>[0]) => updateSection(params),
onMutate: async params => {
const { spaceId, boardId } = params;
const previousData = queryClient.getQueryData(boardQueries.board({ spaceId, boardId }).queryKey);
queryClient.setQueryData(boardQueries.board({ spaceId, boardId }).queryKey, oldData => {
if (!oldData) return oldData;

const oldSection = oldData.sections.find(s => s.sectionId === sectionId);
if (!oldSection) return oldData;

const newSection = { ...oldSection, ...updateInfo };
const newSections = [...oldData.sections.map(s => (s.sectionId === sectionId ? newSection : s))];
const newSectionIndex = oldData.sections.findIndex(s => s.sectionId === sectionId);

newSections.splice(newSectionIndex, 1);
newSections.splice(updateInfo.order ?? newSectionIndex, 0, newSection);

const orderedSections = newSections.map((s, index) => ({
...s,
order: index,
}));

return {
...oldData,
sections: orderedSections,
};
});
return { spaceId, boardId, previousData };
},
onError: (_, __, context) => {
if (context?.previousData) {
const { spaceId, boardId } = context;
queryClient.setQueryData(boardQueries.board({ spaceId, boardId }).queryKey, context.previousData);
}
},
});
};
// mutate
export const useUpdateSection = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (params: Parameters<typeof updateSection>[0]) => updateSection(params),
onMutate: async params => {
const { spaceId, boardId } = params;
const previousData = queryClient.getQueryData(boardQueries.board({ spaceId, boardId }).queryKey);
queryClient.setQueryData(boardQueries.board({ spaceId, boardId }).queryKey, oldData => {
if (!oldData) return oldData;

const oldSection = oldData.sections.find(s => s.sectionId === sectionId);
if (!oldSection) return oldData;

const newSection = { ...oldSection, ...updateInfo };
const newSections = [...oldData.sections.map(s => (s.sectionId === sectionId ? newSection : s))];
const newSectionIndex = oldData.sections.findIndex(s => s.sectionId === sectionId);

newSections.splice(newSectionIndex, 1);
newSections.splice(updateInfo.order ?? newSectionIndex, 0, newSection);

const orderedSections = newSections.map((s, index) => ({
...s,
order: index,
}));

return {
...oldData,
sections: orderedSections,
};
});
return { spaceId, boardId, previousData };
},
onError: (_, __, context) => {
if (context?.previousData) {
const { spaceId, boardId } = context;
queryClient.setQueryData(boardQueries.board({ spaceId, boardId }).queryKey, context.previousData);
}
},
});
};
conscious-sapphire
conscious-sapphire9mo ago
I made a kanban board some time ago here: https://github.com/TkDodo/trellix-query
GitHub
GitHub - TkDodo/trellix-query
Contribute to TkDodo/trellix-query development by creating an account on GitHub.
fair-rose
fair-roseOP9mo ago
Thanks for the comment! However, it seems a bit unrelated to the issue I’m facing… Is it possible that oldData is briefly returned when manipulating data with setQueryData inside onMutate?
conscious-sapphire
conscious-sapphire8mo ago
don't think so. I thought it was relevant because it shows a working drag and drop implementation your select mutates in place, that's not good:
select: state => state.sections.sort((a, b) => a.order - b.order),
select: state => state.sections.sort((a, b) => a.order - b.order),
fair-rose
fair-roseOP8mo ago
I moved the sort function from the state function to the component’s rendering part, but it didn’t make any difference. If there’s no issue with the useMutation I wrote, could the problem be due to the complexity of the query data or the way the component is rendered? This behavior occurs not only with DnD but also with actions like renaming, where it briefly shows the previous data. It seems like setQueryData is working slow.
conscious-sapphire
conscious-sapphire8mo ago
setQueryData is synchronous
fair-rose
fair-roseOP8mo ago
Then, should I manage the state asynchronously using setState?

Did you find this page helpful?