Таблица с разбивкой на страницы приводит к дублированию строк или бесконечному циклу при сортировкеJavascript

Форум по Javascript
Ответить
Anonymous
 Таблица с разбивкой на страницы приводит к дублированию строк или бесконечному циклу при сортировке

Сообщение Anonymous »

Я создаю многоразовый компонент таблицы с разбивкой на страницы, используя React + Inertia.js.
Таблица поддерживает:
  • нумерацию страниц на стороне сервера
  • сортировку
  • поиск (устарело)
  • фильтры
  • массовые действия

Проблема

Я столкнулся с двумя конфликтующими проблемами:
  • Если я использую
preserveState: True

→ сортировка приводит к появлению в таблице повторяющихся строк.
  • Если я изменю ее на
preserveState: false

→ компонент входит в бесконечный цикл запросов при сортировке или фильтрации.

Ожидаемое поведение

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

Быстрый ответ

Изменение регистра текста: 
Смайлики
:) :( :oops: :roll: :wink: :muza: :clever: :sorry: :angel: :read: *x)
Ещё смайлики…
   
К этому ответу прикреплено по крайней мере одно вложение.

Если вы не хотите добавлять вложения, оставьте поля пустыми.

Максимально разрешённый размер вложения: 15 МБ.

Вернуться в «Javascript»