Таблица поддерживает:
- нумерацию страниц на стороне сервера
- сортировку
- поиск (устарело)
- фильтры
- массовые действия
Проблема
Я столкнулся с двумя конфликтующими проблемами:- Если я использую
→ сортировка приводит к появлению в таблице повторяющихся строк.
- Если я изменю ее на
→ компонент входит в бесконечный цикл запросов при сортировке или фильтрации.
Ожидаемое поведение
- Сортировка должна перезагружать данные без дублирования строк
- Бесконечный цикл отсутствует
- Поисковые данные и фильтры должны оставаться контролируемыми (не сбрасываться)
- и, если возможно, я не хочу менять URL-адрес
Я пробовал защитить с помощью 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 ... en-sorting
Мобильная версия