Anonymous
Таблица с разбивкой на страницы Inertia.js React вызывает дублирование строк или бесконечный цикл при сортировке
Сообщение
Anonymous » 26 дек 2025, 22:18
Я создаю многоразовый
компонент таблицы с разбивкой на страницы , используя
React + Inertia.js .
Таблица поддерживает:
нумерацию страниц на стороне сервера
сортировку
поиск (устарело)
фильтры
массовые действия
Проблема
Я столкнулся с
двумя конфликтующими проблемами :
→ сортировка приводит к появлению в таблице
повторяющихся строк .
→ компонент входит в
бесконечный цикл запросов при сортировке или фильтрации.
Ожидаемое поведение
Сортировка должна перезагружать данные без дублирования строк
Бесконечный цикл отсутствует
Поисковые данные и фильтры должны оставаться контролируемыми (не сбрасываться)
и, если возможно, я не хочу менять URL-адрес
Когда параметр saveState имеет значение false, Inertia перемонтирует компонент, что, похоже, повторно запускает эффект и вызывает бесконечный цикл.
Я пробовал защитить с помощью useRef (пропустить первый рендеринг), эффекты устранения дребезга и разделения, но цикл все равно происходит.
есть ли какое-либо решение этой проблемы?
ниже приведен полный код моего компонента
Код: Выделить всё
import React, { useEffect, useRef, useState } from 'react';
import { router } from '@inertiajs/react';
/* =====================
TYPES
===================== */
type Column = {
key: string;
label: string;
data_type?: string;
sortable?: boolean;
render?: (value: any, row: any) => React.ReactNode;
};
type FilterOption = {
label: string;
value: string | number;
};
type Filter = {
key: string;
label: string;
type: 'text' | 'select';
options?: FilterOption[];
placeholder?: string;
defaultValue?: any;
};
type PagedData = {
data: T[];
current_page: number;
last_page: number;
};
type RowAction = {
key: string;
label: string;
onClick: (row: T) => void;
danger?: boolean;
can?: (row: T) => boolean;
className?: string;
};
type BulkAction = {
key: string;
label: string;
onClick: (rows: T[]) => void;
danger?: boolean;
};
/* =====================
COMPONENT
===================== */
export default function PaginatedTable({
data,
columns,
fetchUrl,
initialSearch = '',
rowActions = [],
bulkActions = [],
filters = [],
}: {
data: PagedData;
columns: Column[];
fetchUrl: string;
initialSearch?: string;
rowActions?: RowAction[];
bulkActions?: BulkAction[];
filters?: Filter[];
}) {
/* =====================
STATE
===================== */
const [search, setSearch] = useState(initialSearch);
const [loading, setLoading] = useState(false);
const [sortBy, setSortBy] = useState(null);
const [sortDir, setSortDir] = useState(null);
const [filterValues, setFilterValues] = useState({});
const [selectedIds, setSelectedIds] = useState([]);
const debounceRef = useRef(null);
const isFirstRender = useRef(true);
const prevDataRef = useRef(data); // Track previous data
/* =====================
INIT FILTER DEFAULTS
===================== */
useEffect(() => {
const defaults: Record = {};
filters.forEach(f => {
if (f.defaultValue !== undefined) {
defaults[f.key] = f.defaultValue;
}
});
setFilterValues(defaults);
}, []);
/* =====================
DEBOUNCED SEARCH
===================== */
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = window.setTimeout(() => {
doFetch(1); // Reset to page 1 on search
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
/* =====================
AUTO FETCH ON SORT/FILTER
===================== */
useEffect(() => {
// Skip initial render and only fetch when sort/filter actually changes
if (isFirstRender.current) return;
doFetch(1); // Reset to page 1 when sorting/filtering
}, [sortBy, sortDir, filterValues]);
/* =====================
CLEAR SELECTION ON DATA CHANGE
Also prevent duplicate data by checking if data actually changed
===================== */
useEffect(() => {
// Check if data actually changed to avoid unnecessary selection clearing
if (JSON.stringify(prevDataRef.current.data.map(d => d.id)) !==
JSON.stringify(data.data.map(d => d.id))) {
setSelectedIds([]);
prevDataRef.current = data;
}
}, [data]);
/* =====================
FETCH
===================== */
const doFetch = (page: number = 1) => {
setLoading(true);
// Build query params
const params: any = {
page,
...(search && { search }),
...(sortBy && { sort: sortBy }),
...(sortDir && { direction: sortDir }),
...(Object.keys(filterValues).length > 0 && { filters: filterValues }),
};
router.get(
fetchUrl,
params,
{
preserveScroll: true,
preserveState: true,
replace: true,
only: ['data'],
onFinish: () => setLoading(false),
onError: () => setLoading(false),
}
);
};
/* =====================
SORT HANDLER
===================== */
const handleSort = (columnKey: string) => {
let newSortBy: string | null = sortBy;
let newSortDir: 'asc' | 'desc' | null = sortDir;
if (sortBy !== columnKey) {
// New column, start with asc
newSortBy = columnKey;
newSortDir = 'asc';
} else {
// Same column, cycle through: asc → desc → none
if (sortDir === 'asc') {
newSortDir = 'desc';
} else if (sortDir === 'desc') {
newSortBy = null;
newSortDir = null;
}
}
setSortBy(newSortBy);
setSortDir(newSortDir);
};
/* =====================
SELECTION
===================== */
const toggleSelectAll = () => {
setSelectedIds(
selectedIds.length === data.data.length
? []
: data.data.map(r => r.id)
);
};
const toggleSelectRow = (id: number) => {
setSelectedIds(ids =>
ids.includes(id) ? ids.filter(i => i !== id) : [...ids, id]
);
};
const selectedRows = data.data.filter(r => selectedIds.includes(r.id));
const isAllSelected =
selectedIds.length === data.data.length && data.data.length > 0;
const isSomeSelected =
selectedIds.length > 0 && selectedIds.length < data.data.length;
/* =====================
RENDER
===================== */
return (
{/* Toolbar */}
setSearch(e.target.value)}
placeholder="Search..."
className="h-10 w-64 rounded-lg border px-3 text-sm"
/>
{/* Filters */}
{filters.length > 0 && (
{filters.map(filter => (
{filter.type === 'text' && (
setFilterValues({
...filterValues,
[filter.key]: e.target.value,
})
}
placeholder={filter.placeholder || filter.label}
className="h-10 rounded-lg border px-3 text-sm"
/>
)}
{filter.type === 'select' && (
setFilterValues({
...filterValues,
[filter.key]: e.target.value,
})
}
className="h-10 rounded-lg border px-3 text-sm"
>
{filter.placeholder || filter.label}
{filter.options?.map(opt => (
{opt.label}
))}
)}
))}
)}
{/* Bulk actions */}
{bulkActions.length > 0 && selectedIds.length > 0 && (
{selectedIds.length} selected
{bulkActions.map(action => (
action.onClick(selectedRows)}
className={`px-3 py-2 rounded-lg border text-sm ${
action.danger
? 'border-red-500 text-red-600 hover:bg-red-50'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
{action.label}
))}
)}
{/* Table */}
{loading && (
)}
{bulkActions.length > 0 && (
{
if (el) el.indeterminate = isSomeSelected;
}}
onChange={toggleSelectAll}
/>
)}
{columns.map(col => (
{
if (!col.sortable) return;
handleSort(col.key);
}}
>
{col.label}
{sortBy === col.key && (
{sortDir === 'asc' ? '▲' : '▼'}
)}
))}
{rowActions.length > 0 && (
Actions
)}
{data.data.length === 0 && (
No data found
)}
{data.data.map((row: any) => (
{bulkActions.length > 0 && (
toggleSelectRow(row.id)}
/>
)}
{columns.map(col => {
const value = col.key
.split('.')
.reduce((o, k) => o?.[k], row);
return (
{col.render
? col.render(value, row)
: col.data_type === 'currency'
? new Intl.NumberFormat('id-ID').format(value)
: value ?? '-'}
);
})}
{rowActions.length > 0 && (
{rowActions
.filter(a => !a.can || a.can(row))
.map(a => (
a.onClick(row)}
className={`px-3 py-1 rounded-lg border text-sm ${
a.danger
? 'border-red-500 text-red-600 hover:bg-red-50'
: 'border-gray-300 hover:bg-gray-50'
} ${a.className ?? ''}`}
>
{a.label}
))}
)}
))}
{/* Pagination */}
Previous
Page {data.current_page} of {data.last_page}
= data.last_page || loading}
onClick={() => doFetch(data.current_page + 1)}
className="border rounded-lg px-3 py-2 text-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
);
}
Заранее благодарим за любую помощь или предложение.
Подробнее здесь:
https://stackoverflow.com/questions/798 ... p-when-sor
1766776716
Anonymous
Я создаю многоразовый [b]компонент таблицы с разбивкой на страницы[/b], используя [b]React + Inertia.js[/b]. Таблица поддерживает: [list] [*]нумерацию страниц на стороне сервера [*]сортировку [*]поиск (устарело) [*]фильтры [*]массовые действия [/list] [h4]Проблема[/h4] Я столкнулся с [b]двумя конфликтующими проблемами[/b]: [list] [*]Если я использую [/list] [code]preserveState: True [/code] → сортировка приводит к появлению в таблице [b]повторяющихся строк[/b]. [list] [*]Если я изменю ее на [/list] [code]preserveState: false [/code] → компонент входит в [b]бесконечный цикл запросов[/b] при сортировке или фильтрации. [h4]Ожидаемое поведение[/h4] [list] [*]Сортировка должна перезагружать данные [b]без дублирования строк[/b] [*]Бесконечный цикл отсутствует [*]Поисковые данные и фильтры должны оставаться контролируемыми (не сбрасываться) [*]и, если возможно, я не хочу менять URL-адрес [/list] Когда параметр saveState имеет значение false, Inertia перемонтирует компонент, что, похоже, повторно запускает эффект и вызывает бесконечный цикл. Я пробовал защитить с помощью useRef (пропустить первый рендеринг), эффекты устранения дребезга и разделения, но цикл все равно происходит. есть ли какое-либо решение этой проблемы? ниже приведен полный код моего компонента [code]import React, { useEffect, useRef, useState } from 'react'; import { router } from '@inertiajs/react'; /* ===================== TYPES ===================== */ type Column = { key: string; label: string; data_type?: string; sortable?: boolean; render?: (value: any, row: any) => React.ReactNode; }; type FilterOption = { label: string; value: string | number; }; type Filter = { key: string; label: string; type: 'text' | 'select'; options?: FilterOption[]; placeholder?: string; defaultValue?: any; }; type PagedData = { data: T[]; current_page: number; last_page: number; }; type RowAction = { key: string; label: string; onClick: (row: T) => void; danger?: boolean; can?: (row: T) => boolean; className?: string; }; type BulkAction = { key: string; label: string; onClick: (rows: T[]) => void; danger?: boolean; }; /* ===================== COMPONENT ===================== */ export default function PaginatedTable({ data, columns, fetchUrl, initialSearch = '', rowActions = [], bulkActions = [], filters = [], }: { data: PagedData; columns: Column[]; fetchUrl: string; initialSearch?: string; rowActions?: RowAction[]; bulkActions?: BulkAction[]; filters?: Filter[]; }) { /* ===================== STATE ===================== */ const [search, setSearch] = useState(initialSearch); const [loading, setLoading] = useState(false); const [sortBy, setSortBy] = useState(null); const [sortDir, setSortDir] = useState(null); const [filterValues, setFilterValues] = useState({}); const [selectedIds, setSelectedIds] = useState([]); const debounceRef = useRef(null); const isFirstRender = useRef(true); const prevDataRef = useRef(data); // Track previous data /* ===================== INIT FILTER DEFAULTS ===================== */ useEffect(() => { const defaults: Record = {}; filters.forEach(f => { if (f.defaultValue !== undefined) { defaults[f.key] = f.defaultValue; } }); setFilterValues(defaults); }, []); /* ===================== DEBOUNCED SEARCH ===================== */ useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; return; } if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = window.setTimeout(() => { doFetch(1); // Reset to page 1 on search }, 400); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [search]); /* ===================== AUTO FETCH ON SORT/FILTER ===================== */ useEffect(() => { // Skip initial render and only fetch when sort/filter actually changes if (isFirstRender.current) return; doFetch(1); // Reset to page 1 when sorting/filtering }, [sortBy, sortDir, filterValues]); /* ===================== CLEAR SELECTION ON DATA CHANGE Also prevent duplicate data by checking if data actually changed ===================== */ useEffect(() => { // Check if data actually changed to avoid unnecessary selection clearing if (JSON.stringify(prevDataRef.current.data.map(d => d.id)) !== JSON.stringify(data.data.map(d => d.id))) { setSelectedIds([]); prevDataRef.current = data; } }, [data]); /* ===================== FETCH ===================== */ const doFetch = (page: number = 1) => { setLoading(true); // Build query params const params: any = { page, ...(search && { search }), ...(sortBy && { sort: sortBy }), ...(sortDir && { direction: sortDir }), ...(Object.keys(filterValues).length > 0 && { filters: filterValues }), }; router.get( fetchUrl, params, { preserveScroll: true, preserveState: true, replace: true, only: ['data'], onFinish: () => setLoading(false), onError: () => setLoading(false), } ); }; /* ===================== SORT HANDLER ===================== */ const handleSort = (columnKey: string) => { let newSortBy: string | null = sortBy; let newSortDir: 'asc' | 'desc' | null = sortDir; if (sortBy !== columnKey) { // New column, start with asc newSortBy = columnKey; newSortDir = 'asc'; } else { // Same column, cycle through: asc → desc → none if (sortDir === 'asc') { newSortDir = 'desc'; } else if (sortDir === 'desc') { newSortBy = null; newSortDir = null; } } setSortBy(newSortBy); setSortDir(newSortDir); }; /* ===================== SELECTION ===================== */ const toggleSelectAll = () => { setSelectedIds( selectedIds.length === data.data.length ? [] : data.data.map(r => r.id) ); }; const toggleSelectRow = (id: number) => { setSelectedIds(ids => ids.includes(id) ? ids.filter(i => i !== id) : [...ids, id] ); }; const selectedRows = data.data.filter(r => selectedIds.includes(r.id)); const isAllSelected = selectedIds.length === data.data.length && data.data.length > 0; const isSomeSelected = selectedIds.length > 0 && selectedIds.length < data.data.length; /* ===================== RENDER ===================== */ return ( {/* Toolbar */} setSearch(e.target.value)} placeholder="Search..." className="h-10 w-64 rounded-lg border px-3 text-sm" /> {/* Filters */} {filters.length > 0 && ( {filters.map(filter => ( {filter.type === 'text' && ( setFilterValues({ ...filterValues, [filter.key]: e.target.value, }) } placeholder={filter.placeholder || filter.label} className="h-10 rounded-lg border px-3 text-sm" /> )} {filter.type === 'select' && ( setFilterValues({ ...filterValues, [filter.key]: e.target.value, }) } className="h-10 rounded-lg border px-3 text-sm" > {filter.placeholder || filter.label} {filter.options?.map(opt => ( {opt.label} ))} )} ))} )} {/* Bulk actions */} {bulkActions.length > 0 && selectedIds.length > 0 && ( {selectedIds.length} selected {bulkActions.map(action => ( action.onClick(selectedRows)} className={`px-3 py-2 rounded-lg border text-sm ${ action.danger ? 'border-red-500 text-red-600 hover:bg-red-50' : 'border-gray-300 hover:bg-gray-50' }`} > {action.label} ))} )} {/* Table */} {loading && ( )} {bulkActions.length > 0 && ( { if (el) el.indeterminate = isSomeSelected; }} onChange={toggleSelectAll} /> )} {columns.map(col => ( { if (!col.sortable) return; handleSort(col.key); }} > {col.label} {sortBy === col.key && ( {sortDir === 'asc' ? '▲' : '▼'} )} ))} {rowActions.length > 0 && ( Actions )} {data.data.length === 0 && ( No data found )} {data.data.map((row: any) => ( {bulkActions.length > 0 && ( toggleSelectRow(row.id)} /> )} {columns.map(col => { const value = col.key .split('.') .reduce((o, k) => o?.[k], row); return ( {col.render ? col.render(value, row) : col.data_type === 'currency' ? new Intl.NumberFormat('id-ID').format(value) : value ?? '-'} ); })} {rowActions.length > 0 && ( {rowActions .filter(a => !a.can || a.can(row)) .map(a => ( a.onClick(row)} className={`px-3 py-1 rounded-lg border text-sm ${ a.danger ? 'border-red-500 text-red-600 hover:bg-red-50' : 'border-gray-300 hover:bg-gray-50' } ${a.className ?? ''}`} > {a.label} ))} )} ))} {/* Pagination */} Previous Page {data.current_page} of {data.last_page} = data.last_page || loading} onClick={() => doFetch(data.current_page + 1)} className="border rounded-lg px-3 py-2 text-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" > Next ); } [/code] [h4]Заранее благодарим за любую помощь или предложение.[/h4] Подробнее здесь: [url]https://stackoverflow.com/questions/79855490/inertia-js-react-paginated-table-causes-duplicate-rows-or-infinite-loop-when-sor[/url]