Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save atomjoy/2fcdef903b4cb843418a923a83340da2 to your computer and use it in GitHub Desktop.
Tanstack table with vue-table, vue-query Laravel 13 Vue Inertia API.

Tanstack v9 useQuery v5 Laravel Inertia Vue

Laravel 13 Inertia, Vue i zapytania z useQuery do api.

Instalacja

npm i @tanstack/vue-table@9
npm i @tanstack/vue-query@5

Api route api.php

/api/users

<?php

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// ->middleware('auth:sanctum');
// Route: /api/users
Route::get('/users', function (Request $request) {
        $request->validate([
            'sort' => 'sometimes|string',
            'direction' => 'sometimes|string|in:asc,desc',
            'per_page' => 'sometimes|integer|min:1|max:100',
        ]);

        $query = User::query();

        // 2. Sortowanie (identycznie jak wcześniej)
        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);

        // Sorting example
        // return response()->json(['items' => User::all()]); // sorting

        // Zwraca pełny obiekt Laravel LengthAwarePaginator
        return response()->json($users);
})->name('api.users');

Controller Route web.php

/users

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;

class UserController extends Controller
{
    public function index(Request $request): Response
    {
        $users = User::all();

		// Change to user or items
		return Inertia::render('admin/users/table/Index', [
			'data' => $users,
			// 'filters' => $request->only(['search', 'sort', 'direction', 'per_page', 'roles', 'start_date', 'end_date']),
			// 'roleFacets' => $this->getRoleFacets($request),
		]);
    }
}

Laravel 13 Vue Inertia

admin/users/table/Index.vue

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

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

// Definicja typu odpowiedzi z Laravela
type LaravelPaginationResponse = {
	data: User[]
	current_page: number
	last_page: number
	per_page: number
	total: number
}

// Get data
const fetchUsers = async (
	currentSorting: SortingState,
	currentPagination: PaginationState,
): Promise<LaravelPaginationResponse> => {
	// TanStack Table indeksuje od 0, Laravel od 1, stąd "+ 1"
	const page = currentPagination.pageIndex + 1
	const perPage = currentPagination.pageSize

	let url = `/api/users?page=${page}&per_page=${perPage}`

	if (currentSorting.length > 0) {
		const sortInfo = currentSorting[0]
		const sortBy = sortInfo.id
		const order = sortInfo.desc ? 'desc' : 'asc'
		url += `&sort=${sortBy}&direction=${order}`
	}

	const res = await fetch(url)
	if (!res.ok) throw new Error('Błąd pobierania użytkowników')
	return res.json()
}

const sorting = ref<SortingState>([])
const pagination = ref<PaginationState>({ pageIndex: 0, pageSize: 10 })

// Integracja z TanStack Query (Query Key obserwuje teraz stany)
const { data, isLoading, error } = useQuery({
	queryKey: ['users', sorting, pagination],
	queryFn: () => fetchUsers(sorting.value, pagination.value),
})
const tableData = computed(() => data.value?.data ?? [])
const pageCount = computed(() => data.value?.last_page ?? -1)

// Rejestrujemy funkcjonalności dla v9
const features = tableFeatures({
	columnVisibilityFeature,
	rowSortingFeature,
	rowPaginationFeature,
})

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,
	manualPagination: true,
	pageCount: pageCount.value,
	state: {
		get sorting() {
			return sorting.value
		},
		get pagination() {
			return pagination.value
		},
	},
	onSortingChange: (updater: Updater<SortingState>) => {
		sorting.value = typeof updater === 'function' ? updater(sorting.value) : updater
		pagination.value.pageIndex = 0
	},
	onPaginationChange: (updater: Updater<PaginationState>) => {
		pagination.value = typeof updater === 'function' ? updater(pagination.value) : updater
	},
})
</script>

<template>
	<div style="padding: 20px">
		<div v-if="isLoading">Ładowanie użytkowników...</div>
		<div v-else-if="error">Błąd: {{ error.message }}</div>

		<div v-else>
			<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 zgodne z v9 (Vue) -->
			<div
				style="
					margin-top: 15px;
					display: flex;
					align-items: center;
					gap: 10px;
					font-family: sans-serif;
				"
			>
				<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, 20, 50]" :key="size" :value="size">
						Pokaż {{ size }}
					</option>
				</select>
			</div>
		</div>
	</div>
</template>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment