Skip to content

Instantly share code, notes, and snippets.

@atomjoy
Last active September 4, 2026 15:55
Show Gist options
  • Select an option

  • Save atomjoy/b71c23da6eac2352966319c110ecc6c5 to your computer and use it in GitHub Desktop.

Select an option

Save atomjoy/b71c23da6eac2352966319c110ecc6c5 to your computer and use it in GitHub Desktop.
Tanstack Inertia Laravel Vue

Tanstack v9 Vue Inertia

Table with sorting, pagination, text search.

Controller

public function index(Request $request){
	$query = User::query();

	if ($request->has('search')) {
		$search = $request->input('search');

		$query->where(function ($q) use ($search) {
			$q->where('name', 'like', "%{$search}%")
			  ->orWhere('email', 'like', "%{$search}%");
		});
	}

	if ($request->has('sort')) {
		$sortBy = $request->input('sort');
		$direction = $request->input('direction', 'asc');
		$allowedColumns = ['id', 'name', 'email', 'role'];

		if (in_array($sortBy, $allowedColumns)) {
			$query->orderBy($sortBy, $direction);
		}
	} else {
		$query->latest('id');
	}

	$perPage = $request->input('per_page', 10);
	$users = $query->paginate($perPage)->withQueryString();

	return Inertia::render('admin/users/table/Index', [
		'users' => $users,
	]);
}

Vue

admin/users/table/Index.vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import {
	useTable,
	tableFeatures,
	columnVisibilityFeature,
	rowSortingFeature,
	createColumnHelper,
	FlexRender,
	type Updater,
	type SortingState,
	type PaginationState,
} from '@tanstack/vue-table'

type User = {
	id: number
	name: string
	email: string
	role: string
}

type LaravelPagination = {
	data: User[]
	current_page: number
	last_page: number
	per_page: number
	total: number
}

const props = defineProps<{
	users: LaravelPagination
}>()

const urlParams = new URLSearchParams(window.location.search)

// 1. Stan dla wyszukiwarki (pobiera wartość z URL przy F5)
const searchQuery = ref(urlParams.get('search') ?? '')
const debouncedSearch = ref(searchQuery.value)

const sorting = ref<SortingState>(
	urlParams.get('sort')
		? [{ id: urlParams.get('sort') as string, desc: urlParams.get('direction') === 'desc' }]
		: [],
)

const pagination = ref<PaginationState>({
	pageIndex: urlParams.get('page') ? Number(urlParams.get('page')) - 1 : 0,
	pageSize: urlParams.get('per_page') ? Number(urlParams.get('per_page')) : 10,
})

// Prosty mechanizm debouncingu dla inputu wyszukiwania
let timeoutId: ReturnType<typeof setTimeout>
watch(searchQuery, (newVal) => {
	clearTimeout(timeoutId)
	timeoutId = setTimeout(() => {
		debouncedSearch.value = newVal
		pagination.value.pageIndex = 0 // Reset do 1. strony, gdy użytkownik zmienia frazę
	}, 350) // opóźnienie 350ms przed wysłaniem zapytania
})

// 3. Synchronizacja z paskiem adresu URL (uwzględnia debouncedSearch)
watch(
	[sorting, pagination, debouncedSearch],
	([newSorting, newPagination, newSearch]) => {
		const query: Record<string, string | number> = {
			page: newPagination.pageIndex + 1,
			per_page: newPagination.pageSize,
		}

		if (newSorting.length > 0) {
			const firstSort = newSorting[0]
			query.sort = firstSort.id
			query.direction = firstSort.desc ? 'desc' : 'asc'
		}

		// Zawsze przekazujemy search, nawet pusty string, żeby wyczyścić filtry w Laravelu
		query.search = newSearch

		// Prawidłowy zapis opcji dla nowej wersji Inertii
		router.reload({
			data: query,
			only: ['users'], // Odświeża tylko dane tabeli (Partial Reload)
		})
	},
	{ deep: true },
)

const tableData = computed(() => props.users.data)
const pageCount = computed(() => props.users.last_page)

const features = tableFeatures({
	columnVisibilityFeature,
	rowSortingFeature,
})

const columnHelper = createColumnHelper<typeof features, User>()
const columns = columnHelper.columns([
	columnHelper.accessor('id', { header: 'ID' }),
	columnHelper.accessor('name', { header: 'Imię i nazwisko' }),
	columnHelper.accessor('email', { header: 'Adres E-mail' }),
	columnHelper.accessor('role', { header: 'Rola' }),
])

const table = useTable({
	features,
	get data() {
		return tableData.value
	},
	columns,
	manualSorting: true,
	state: {
		get sorting() {
			return sorting.value
		},
	},
	onSortingChange: (updater: Updater<SortingState>) => {
		sorting.value = typeof updater === 'function' ? updater(sorting.value) : updater
		pagination.value.pageIndex = 0
	},
})
</script>

<template>
	<div style="padding: 20px; font-family: sans-serif">
		<div style="margin-bottom: 15px">
			<input
				v-model="searchQuery"
				type="text"
				placeholder="Szukaj użytkownika..."
				style="padding: 8px 12px; width: 300px; border: 1px solid #ccc; border-radius: 4px"
			/>
		</div>

		<table style="width: 100%; border-collapse: collapse; text-align: left">
			<thead>
				<tr
					v-for="headerGroup in table.getHeaderGroups()"
					:key="headerGroup.id"
					style="border-bottom: 2px solid #ccc"
				>
					<th
						v-for="header in headerGroup.headers"
						:key="header.id"
						style="padding: 10px; user-select: none"
					>
						<div
							v-if="!header.isPlaceholder"
							:style="
								header.column.getCanSort()
									? 'cursor: pointer; display: flex; gap: 8px;'
									: ''
							"
							@click="header.column.getToggleSortingHandler()?.($event)"
						>
							<FlexRender
								:render="header.column.columnDef.header"
								:props="header.getContext()"
							/>
							<span v-if="header.column.getIsSorted() === 'asc'">🔼</span>
							<span v-else-if="header.column.getIsSorted() === 'desc'">🔽</span>
						</div>
					</th>
				</tr>
			</thead>
			<tbody>
				<tr
					v-for="row in table.getRowModel().rows"
					:key="row.id"
					style="border-bottom: 1px solid #eee"
				>
					<td v-for="cell in row.getVisibleCells()" :key="cell.id" style="padding: 10px">
						<FlexRender
							:render="cell.column.columnDef.cell"
							:props="cell.getContext()"
						/>
					</td>
				</tr>
			</tbody>
		</table>

		<!-- Kontrolki paginacji dolnej -->
		<div style="margin-top: 15px; display: flex; align-items: center; gap: 10px">
			<button
				:disabled="pagination.pageIndex === 0"
				@click="pagination.pageIndex--"
				style="padding: 5px 10px; cursor: pointer"
			>
				Poprzednia
			</button>

			<span>
				Strona <strong>{{ pagination.pageIndex + 1 }}</strong> z
				<strong>{{ pageCount }}</strong>
			</span>

			<button
				:disabled="pagination.pageIndex + 1 >= pageCount"
				@click="pagination.pageIndex++"
				style="padding: 5px 10px; cursor: pointer"
			>
				Następna
			</button>

			<select
				:value="pagination.pageSize"
				@change="
					((pagination.pageSize = Number(($event.target as HTMLSelectElement).value)),
					(pagination.pageIndex = 0))
				"
				style="padding: 5px"
			>
				<option v-for="size in [1, 5, 10, 25, 50]" :key="size" :value="size">
					Pokaż {{ size }}
				</option>
			</select>
		</div>
	</div>
</template>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment