Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save atomjoy/ce5ddb77181530dc0f1a853c2f0b33b2 to your computer and use it in GitHub Desktop.
Tanstack query

Tanstack Query

TanStack Query (dawniej znany jako React Query) to potężna biblioteka do zarządzania asynchronicznym stanem aplikacji (tzw. server state). Pomaga w pobieraniu (fetching), cache'owaniu, synchronizowaniu oraz aktualizowaniu danych pochodzących z serwera, eliminując potrzebę pisania skomplikowanego kodu opartego na useEffect i useState.

Install

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

Import

import { VueQueryPlugin } from '@tanstack/vue-query'
// Add in withApp
app.use(VueQueryPlugin)

Inertia router

import { router } from '@inertiajs/vue'

const newTodo = { todo: 'Kupić komputer', completed: 'false' }

// Dla lokalnych zapytań (fetch dla zewnętrznego api)
router.post('/todos', newTodo, {
  onBefore: () => {}, // Loader
  onSuccess: () => {},
  onError: (errors) => {},
})

GET

Reaktywne zapytania (zależność od ref)

<script setup>
import { useQuery } from '@tanstack/vue-query'
import { ref } from 'vue'

const todoId = ref(1)

const { isPending, isError, data, error } = useQuery({
  queryKey: ['todos', todoId],
  // Z fetch
  queryFn: () =>
    fetch(`https://dummyjson.com/todos/${todoId.value}`).then((res) =>
      res.json(),
    ),
  // Z async/await
  queryFn: async () => {
    const res = await fetch(`/todos/${todoId.value}`)
    if (!res.ok) throw new Error('Błąd pobierania danych')
    return res.json()
  },
})
</script>
<template>
  <h1>ToDo List</h1>
  <button @click="todoId++">Następne zadanie (ID: {{ todoId }})</button>
  <div v-if="isPending">Ładowanie zadań...</div>
  <div v-else-if="isError">Wystąpił błąd: {{ error.message }}</div>
  <ul v-else>
    <span>{{ data }}</span>
    <li v-for="i in data" :key="i.id">{{ i.todo }}</li>
  </ul>
</template>

POST

Modyfikacja danych (useMutation)

<script setup>
import { router } from '@inertiajs/vue'
import { useMutation, useQueryClient } from '@tanstack/vue-query'

const createTodo = (newTodo) => {
  router.post('https://dummyjson.com/todos/add', newTodo, {
    onSuccess: () => {
      console.log('Zadanie dodane pomyślnie!')
    },
    onError: (errors) => {
      console.error('Błędy walidacji z serwera:', errors)
    },
  })
}

const queryClient = useQueryClient()
const { mutate, isPending } = useMutation({
  mutationFn: async (newTodo) => {
    const res = await fetch('https://dummyjson.com/todos/add', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(newTodo),
    })
    if (!res.ok) {
      throw new Error('Błąd serwera')
    }
    return res.json()
  },
  // Po udanym zapisie informujemy system, że lista 'todos' jest nieaktualna
  onSuccess: () => {
    // Odświeży wszystkie zadania z kluczami ['todos'], ['todos', { type: 'done' }], ['todos', 1]
    queryClient.invalidateQueries({ queryKey: ['todos'] })
    // Wyłącznie zadania z kluczem 'toodo'
    // queryClient.invalidateQueries({ queryKey: ['todos'], exact: true })
  },
})

const addTodo = () => {
  mutate({ title: 'Nowe zadanie do zrobienia', completed: false })
}
</script>

<template>
  <button :disabled="isPending" @click="addTodo">
    {{ isPending ? 'Dodawanie...' : 'Dodaj zadanie' }}
  </button>
</template>

Sample

<script setup>
import { useQueryClient, useQuery, useMutation } from '@tanstack/vue-query'

// Access QueryClient instance
const queryClient = useQueryClient()

// Query
const { isPending, isError, data, error } = useQuery({
  queryKey: ['todos'],
  queryFn: getTodos,
})

// Mutation
const mutation = useMutation({
  mutationFn: postTodo,
  onSuccess: () => {
    // Invalidate and refetch
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

function onButtonClick() {
  mutation.mutate({
    id: Date.now(),
    title: 'Do Laundry',
  })
}
</script>

<template>
  <span v-if="isPending">Loading...</span>
  <span v-else-if="isError">Error: {{ error.message }}</span>
  <!-- We can assume by this point that `isSuccess === true` -->
  <ul v-else>
    <li v-for="todo in data" :key="todo.id">{{ todo.title }}</li>
  </ul>
  <button @click="onButtonClick">Add Todo</button>
</template>

Update optimistic

<script setup>
import { useMutation, useQueryClient } from '@tanstack/vue-query'

const queryClient = useQueryClient()
const queryKey = ['todos']

const { mutate } = useMutation({
  // 1. Definiujemy funkcję wysyłającą dane na serwer
  mutationFn: (newTodo) => {
    return fetch('https://dummyjson.com/todos/add', {
      method: 'POST',
      body: JSON.stringify(newTodo),
      headers: { 'Content-Type': 'application/json' },
    }).then((res) => {
      if (!res.ok) throw new Error('Błąd serwera')
      return res.json()
    })
  },

  // 2. Wywoływane po kliknięciu (przed zapytaniem sieciowym)
  onMutate: async (newTodo) => {
    // Anulujemy wychodzące zapytania dla tego klucza, aby nie nadpisały naszej optymistycznej zmiany
    await queryClient.cancelQueries({ queryKey })

    // Pobieramy i zapamiętujemy aktualny stan cache (jako kopię bezpieczeństwa)
    const previousTodos = queryClient.getQueryData(queryKey)

    // Optymistycznie aktualizujemy cache o nowy element (nadajemy mu tymczasowe ID)
    queryClient.setQueryData(queryKey, (old) => {
      const optimisticTodo = {
        id: Date.now(),
        title: newTodo.title,
        completed: false,
      }
      return old ? [...old, optimisticTodo] : [optimisticTodo]
    })

    // Zwracamy kontekst z kopią bezpieczeństwa – trafi on do onError w razie problemów
    return { previousTodos }
  },

  // 3. Wywoływane TYLKO, gdy serwer zwróci błąd
  onError: (err, newTodo, context) => {
    // Przywracamy poprzedni stan danych sprzed kliknięcia
    if (context?.previousTodos) {
      queryClient.setQueryData(queryKey, context.previousTodos)
    }
    alert('Nie udało się dodać zadania. Przywrócono poprzedni stan.')
  },

  // 4. Wywoływane ZAWSZE (zarówno przy sukcesie, jak i przy błędzie)
  onSettled: () => {
    // Synchronizujemy stan lokalny z serwerem, pobierając oficjalną listę w tle
    queryClient.invalidateQueries({ queryKey })
  },
})

const handleAddTodo = () => {
  mutate({ title: 'Moje optymistyczne zadanie' })
}
</script>

<template>
  <button @click="handleAddTodo">Dodaj ekspresowo zadanie</button>
</template>

Delete optimistic

<script setup>
import { useMutation, useQueryClient } from '@tanstack/vue-query'

const queryClient = useQueryClient()
const queryKey = ['todos']

const { mutate: deleteTodo } = useMutation({
  // 1. Funkcja usuwająca element na serwerze
  mutationFn: (todoId) => {
    return fetch(`https://example.com{todoId}`, {
      method: 'DELETE',
    }).then((res) => {
      if (!res.ok) throw new Error('Nie udało się usunąć elementu')
      return res.json()
    })
  },

  // 2. Wywoływane natychmiast po kliknięciu "Usuń"
  onMutate: async (todoId) => {
    // Anulujemy trwające zapytania pobierania, aby nie nadpisały naszej optymistycznej zmiany
    await queryClient.cancelQueries({ queryKey })

    // Zapisujemy aktualny stan cache (kopia bezpieczeństwa)
    const previousTodos = queryClient.getQueryData(queryKey)

    // Optymistycznie usuwamy element z cache – natychmiast znika z ekranu
    queryClient.setQueryData(queryKey, (old) => {
      return old ? old.filter((todo) => todo.id !== todoId) : []
    })

    // Zwracamy kopię bezpieczeństwa w kontekście
    return { previousTodos }
  },

  // 3. Wywoływane tylko w przypadku błędu serwera
  onError: (err, todoId, context) => {
    // Przywracamy element na listę, jeśli serwer odrzucił operację
    if (context?.previousTodos) {
      queryClient.setQueryData(queryKey, context.previousTodos)
    }
    alert('Błąd usuwania! Przywrócono element na listę.')
  },

  // 4. Wywoływane zawsze (sukces lub błąd) – synchronizacja z serwerem
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey })
  },
})
</script>

<template>
  <!-- Przykład użycia w liście -->
  <ul>
    <li v-for="todo in data" :key="todo.id">
      {{ todo.title }}
      <!-- Przekazujemy ID usuwanego zadania bezpośrednio do deleteTodo -->
      <button @click="deleteTodo(todo.id)">Usuń</button>
    </li>
  </ul>
</template>

Update

<script setup>
import { useMutation, useQueryClient } from '@tanstack/vue-query'

const queryClient = useQueryClient()
const queryKey = ['todos']

const { mutate: toggleTodoStatus } = useMutation({
  // 1. Funkcja aktualizująca stan elementu na serwerze (np. metodą PATCH)
  mutationFn: (updatedTodo) => {
    return fetch(`https://example.com{updatedTodo.id}`, {
      method: 'PATCH',
      body: JSON.stringify({ completed: updatedTodo.completed }),
      headers: { 'Content-Type': 'application/json' },
    }).then((res) => {
      if (!res.ok) throw new Error('Nie udało się zaktualizować zadania')
      return res.json()
    })
  },

  // 2. Wywoływane natychmiast po kliknięciu checkboxa
  onMutate: async (updatedTodo) => {
    // Anulujemy trwające zapytania pobierania, aby nie nadpisały naszej zmiany
    await queryClient.cancelQueries({ queryKey })

    // Zapisujemy aktualny stan cache (kopia bezpieczeństwa)
    const previousTodos = queryClient.getQueryData(queryKey)

    // Optymistycznie modyfikujemy konkretny element w cache
    queryClient.setQueryData(queryKey, (old) => {
      if (!old) return []
      return old.map((todo) =>
        todo.id === updatedTodo.id
          ? { ...todo, completed: updatedTodo.completed } // Podmieniamy tylko zmieniony status
          : todo,
      )
    })

    // Zwracamy kopię bezpieczeństwa w kontekście
    return { previousTodos }
  },

  // 3. Wywoływane tylko w przypadku błędu serwera
  onError: (err, updatedTodo, context) => {
    // Przywracamy poprzedni stan (checkbox wróci do starej pozycji)
    if (context?.previousTodos) {
      queryClient.setQueryData(queryKey, context.previousTodos)
    }
    alert('Błąd aktualizacji! Przywrócono poprzedni stan.')
  },

  // 4. Wywoływane zawsze (sukces lub błąd) – synchronizacja stanu z bazą danych
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey })
  },
})
</script>

<template>
  <ul>
    <li v-for="todo in data" :key="todo.id">
      <!-- Przy zmianie przekazujemy obiekt z nowym stanem completed -->
      <input
        type="checkbox"
        :checked="todo.completed"
        @change="toggleTodoStatus({ id: todo.id, completed: !todo.completed })"
      />
      <span :class="{ 'line-through': todo.completed }">{{ todo.title }}</span>
    </li>
  </ul>
</template>

<style scoped>
.line-through {
  text-decoration: line-through;
}
</style>

Tanstack only js

<script setup>
import { ref, computed } from 'vue'
import {
  useVueTable,
  getCoreRowModel,
  getSortedRowModel,
  getPaginationRowModel,
  FlexRender,
} from '@tanstack/vue-table'

// 1. Zwykłe, surowe dane w czystym JS (tablica obiektów)
const data = ref([
  { id: 1, name: 'Jan Kowalski', email: 'jan@wp.pl', role: 'Admin' },
  { id: 2, name: 'Anna Nowak', email: 'anna@o2.pl', role: 'User' },
  { id: 3, name: 'Piotr Zieliński', email: 'piotr@gmail.com', role: 'Editor' },
  { id: 4, name: 'Maria Dąbrowska', email: 'maria@wp.pl', role: 'User' },
  { id: 5, name: 'Tomasz Lewandowski', email: 'tomasz@o2.pl', role: 'User' },
  {
    id: 6,
    name: 'Katarzyna Wiśniewska',
    email: 'kasia@gmail.com',
    role: 'Admin',
  },
])

// 2. Definicja kolumn jako zwykła tablica obiektów konfiguracyjnych
const columns = [
  {
    accessorKey: 'id',
    header: 'ID',
  },
  {
    accessorKey: 'name',
    header: 'Imię i Nazwisko',
  },
  {
    accessorKey: 'email',
    header: 'Adres Email',
  },
  {
    accessorKey: 'role',
    header: 'Rola',
  },
]

// 3. Stan tabeli (potrzebny do sortowania)
const sorting = ref([])

// 4. Inicjalizacja TanStack Table za pomocą zwykłego obiektu konfiguracyjnego
const table = useVueTable({
  // Przekazujemy dane (musi być funkcja zwracająca wartość lub getter)
  get data() {
    return data.value
  },
  columns,
  state: {
    get sorting() {
      return sorting.value
    },
  },
  onSortingChange: (updater) => {
    sorting.value =
      typeof updater === 'function' ? updater(sorting.value) : updater
  },
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  initialState: {
    pagination: {
      pageSize: 3, // pokazujemy po 3 wiersze na stronę, żeby przetestować paginację
    },
  },
})
</script>

<template>
  <div class="table-container">
    <table>
      <thead>
        <!-- Iteracja po nagłówkach grup (TanStack wymaga tego poziomu) -->
        <tr
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <!-- Iteracja po właściwych nagłówkach kolumn -->
          <th
            v-for="header in header.headers"
            :key="header.id"
            @click="header.column.getToggleSortingHandler()?.($event)"
            style="cursor: pointer; user-select: none;"
          >
            <!-- FlexRender to specjalny komponent TanStacka do renderowania tekstów/komponentów w komórce -->
            <FlexRender
              :render="header.column.columnDef.header"
              :props="header.getContext()"
            />

            <!-- Wskaźnik sortowania -->
            <span v-if="header.column.getIsSorted() === 'asc'"> 🔼</span>
            <span v-else-if="header.column.getIsSorted() === 'desc'"> 🔽</span>
          </th>
        </tr>
      </thead>

      <tbody>
        <!-- Iteracja po przefiltrowanych/posortowanych wierszach -->
        <tr v-for="row in table.getRowModel().rows" :key="row.id">
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender
              :render="cell.column.columnDef.cell"
              :props="cell.getContext()"
            />
          </td>
        </tr>
      </tbody>
    </table>

    <!-- Przyciski do obsługi stron (Paginacja) -->
    <div class="pagination-controls">
      <button
        @click="table.previousPage()"
        :disabled="!table.getCanPreviousPage()"
      >
        Poprzednia
      </button>

      <span>
        Strona {{ table.getState().pagination.pageIndex + 1 }} z
        {{ table.getPageCount() }}
      </span>

      <button @click="table.nextPage()" :disabled="!table.getCanNextPage()">
        Następna
      </button>
    </div>
  </div>
</template>

<style scoped>
/* Prosty CSS dla czytelności (żadnego Tailwinda!) */
.table-container {
  font-family: sans-serif;
  margin: 20px;
}
table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 15px;
}
th,
td {
  border: 1px solid #ddd;
  padding: 10px;
  text-align: left;
}
th {
  background-color: #f4f4f4;
}
th:hover {
  background-color: #e9e9e9;
}
.pagination-controls {
  display: flex;
  gap: 15px;
  align-items: center;
}
button {
  padding: 5px 10px;
  cursor: pointer;
}
button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}
</style>

Tanstack Filtrowanie po stronie serwera (Server-side)

<script setup>
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

// 1. Reaktywny stan dla wyszukiwarki (v-model)
const searchQuery = ref('')

// Zamiast dummyjson.com/todos użyjemy ://dummyjson.com...,
// ponieważ API DummyJSON pozwala filtrować zadania po stronie serwera!
const apiUrl = computed(() => {
  return `https://://dummyjson.com${searchQuery.value}`
})

// 2. Pobieranie danych za pomocą TanStack Query (useQuery)
// Przekazujemy apiUrl w kluczu (queryKey), aby useQuery automatycznie
// odpalało się na nowo, gdy zmieni się tekst w wyszukiwarce.
const {
  data: apiResponse,
  isLoading,
  isError,
  error,
} = useQuery({
  queryKey: ['todos', apiUrl],
  queryFn: async () => {
    const res = await fetch(apiUrl.value)
    if (!res.ok) throw new Error('Błąd pobierania danych')
    return res.json()
  },
})

// 3. Wyciągamy samą tablicę 'todos' z odpowiedzi API (jeśli dane już doleciały)
const defaultData = []
const tableData = computed(() => apiResponse.value?.todos ?? defaultData)

// 4. Definicja kolumn dla danych z DummyJSON
const columns = [
  {
    accessorKey: 'id',
    header: 'ID',
  },
  {
    accessorKey: 'todo',
    header: 'Zadanie do wykonania',
  },
  {
    accessorKey: 'completed',
    header: 'Status',
    // Możemy dostosować wygląd komórki za pomocą funkcji cell
    cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
  },
  {
    accessorKey: 'userId',
    header: 'ID Użytkownika',
  },
]

// 5. Konfiguracja TanStack Table w czystym JS
// Ponieważ filtrowanie robi serwer (poprzez useQuery), tabela potrzebuje tylko podstawowego CoreRowModel.
const table = useVueTable({
  get data() {
    return tableData.value
  },
  columns,
  getCoreRowModel: getCoreRowModel(),
})
</script>

<template>
  <div class="table-container">
    <h2>Lista zadań (TanStack Table + Query)</h2>

    <!-- Wyszukiwarka -->
    <div class="search-box">
      <input
        v-model="searchQuery"
        type="text"
        placeholder="Wyszukaj zadanie (np. watch, bake, learn)..."
      />
    </div>

    <!-- Obsługa stanów ładowania i błędów z useQuery -->
    <div v-if="isLoading" class="status-msg">Pobieranie danych z API...</div>
    <div v-else-if="isError" class="status-msg error">
      Błąd: {{ error.message }}
    </div>

    <!-- Właściwa tabela -->
    <table v-else>
      <thead>
        <tr
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <th v-for="header in headerGroup.headers" :key="header.id">
            <FlexRender
              :render="header.column.columnDef.header"
              :props="header.getContext()"
            />
          </th>
        </tr>
      </thead>

      <tbody>
        <!-- Jeśli serwer nic nie znalazł -->
        <tr v-if="table.getRowModel().rows.length === 0">
          <td colspan="4" style="text-align: center; color: #888;">
            Nie znaleziono zadań pasujących do frazy "{{ searchQuery }}"
          </td>
        </tr>

        <!-- Renderowanie wierszy -->
        <tr v-for="row in table.getRowModel().rows" :key="row.id">
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender
              :render="cell.column.columnDef.cell"
              :props="cell.getContext()"
            />
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<style scoped>
.table-container {
  font-family: sans-serif;
  margin: 20px;
  max-width: 800px;
}
.search-box {
  margin-bottom: 20px;
}
.search-box input {
  width: 100%;
  padding: 10px;
  font-size: 16px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
table {
  width: 100%;
  border-collapse: collapse;
}
th,
td {
  border: 1px solid #ddd;
  padding: 12px;
  text-align: left;
}
th {
  background-color: #f4f4f4;
  font-weight: bold;
}
tr:nth-child(even) {
  background-color: #f9f9f9;
}
.status-msg {
  padding: 20px;
  background: #f0f0f0;
  border-radius: 4px;
  text-align: center;
}
.status-msg.error {
  background: #ffebee;
  color: #c62828;
}
</style>

## Tanstack Filtrowanie po stronie klienta (Client-side) – Gdyby API nie miało wyszukiwarki

Gdyby API DummyJSON pozwalało tylko na pobranie wszystkich 300 zadań na raz (/todos), a Ty musiałbyś filtrować je lokalnie w przeglądarce, wtedy i tylko wtedy musiałbyś dodać ustawienia filtrowania do konfiguracji tabeli:

// TAK wyglądałaby konfiguracja, gdyby to TABELA miała filtrować pobrane dane:
import { getFilteredRowModel } from '@tanstack/vue-table'

const table = useVueTable({
  get data() {
    return tableData.value
  }, // tu byłoby całe 300 pozycji
  columns,
  state: {
    // Musisz przekazać tekst z inputa do stanu tabeli
    get globalFilter() {
      return searchQuery.value
    },
  },
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(), // Funkcja wykonująca filtrowanie w JS
})

AdvancedTodoTable.vue

<script setup>
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

// 1. JEDEN OBIEKT na cały stan tabeli (Filtry, Paginacja, Sortowanie)
const tableState = ref({
  search: '',
  status: 'all', // 'all' | 'completed' | 'pending'
  page: 1,
  limit: 10,
  sortBy: 'id',
  order: 'asc', // 'asc' | 'desc'
})

// 2. Automatycznie generowany URL na podstawie naszego obiektu stanu
// DummyJSON obsługuje parametry: ?limit=X&skip=Y&sortBy=Z&order=asc/desc
const apiUrl = computed(() => {
  const { search, page, limit, sortBy, order } = tableState.value
  const skip = (page - 1) * limit

  // Jeśli użytkownik szuka, używamy endpointu /search, jeśli nie - zwykłego /todos
  const endpoint = search ? `/search?q=${search}&` : '?'

  return `https://dummyjson.com{endpoint}limit=${limit}&skip=${skip}&sortBy=${sortBy}&order=${order}`
})

// 3. Pobieranie danych przez TanStack Query
// Przekazujemy CAŁY obiekt tableState do klucza (jako kopię), aby każda zmiana wywołała fetch
const {
  data: apiResponse,
  isLoading,
  isError,
  error,
} = useQuery({
  queryKey: ['todos', computed(() => ({ ...tableState.value }))],
  queryFn: async () => {
    const res = await fetch(apiUrl.value)
    if (!res.ok) throw new Error('Błąd sieci')
    return res.json()
  },
})

// 4. PRZETWARZANIE DANYCH PO STRONIE KLIENTA (Status todo)
// DummyJSON NIE ma w API filtra po statusie (ukończone/nieukończone).
// Dlatego ten konkretny filtr musimy obsłużyć lokalnie za pomocą computed!
const finalTableData = computed(() => {
  const rawTodos = apiResponse.value?.todos ?? []
  const statusFilter = tableState.value.status

  if (statusFilter === 'all') return rawTodos

  return rawTodos.filter((todo) => {
    return statusFilter === 'completed' ? todo.completed : !todo.completed
  })
})

// Wyliczanie łącznej liczby stron (z API)
const totalItems = computed(() => apiResponse.value?.total ?? 0)
const totalPages = computed(() =>
  Math.ceil(totalItems.value / tableState.value.limit),
)

// 5. Definicja kolumn (Natywny JS)
const columns = [
  { accessorKey: 'id', header: 'ID' },
  { accessorKey: 'todo', header: 'Zadanie' },
  {
    accessorKey: 'completed',
    header: 'Status',
    cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
  },
]

// 6. Konfiguracja TanStack Table (Służy nam tylko jako szkielet HTML)
const table = useVueTable({
  get data() {
    return finalTableData.value
  },
  columns,
  getCoreRowModel: getCoreRowModel(),
})

// 7. Funkcja pomocnicza do zmiany sortowania
function handleSort(columnKey) {
  if (tableState.value.sortBy === columnKey) {
    // Odwracamy kierunek, jeśli kliknięto tę samą kolumnę
    tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
  } else {
    // Zmieniamy kolumnę i ustawiamy domyślnie 'asc'
    tableState.value.sortBy = columnKey
    tableState.value.order = 'asc'
  }
  tableState.value.page = 1 // Resetujemy na pierwszą stronę po zmianie sortowania
}

// Funkcja resetująca filtry przy zmianie wyszukiwania
function onSearchInput() {
  tableState.value.page = 1
}
</script>

<template>
  <div class="table-container">
    <h2>Zaawansowana Tabela zadań (Czysty JS)</h2>

    <!-- PANEL FILTRÓW -->
    <div class="filters-panel">
      <!-- Szukanie -->
      <input
        v-model="tableState.search"
        @input="onSearchInput"
        type="text"
        placeholder="Szukaj zadania..."
        class="search-input"
      />

      <!-- Filtr Statusu -->
      <select v-model="tableState.status" class="status-select">
        <option value="all">Wszystkie statusy</option>
        <option value="completed">Tylko ukończone ✅</option>
        <option value="pending">W trakcie ⏳</option>
      </select>
    </div>

    <!-- Obsługa stanów ładowania -->
    <div v-if="isLoading" class="status-msg">Ładowanie...</div>
    <div v-else-if="isError" class="status-msg error">{{ error.message }}</div>

    <!-- TABELA HTML -->
    <table v-else>
      <thead>
        <tr
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <th
            v-for="header in headerGroup.headers"
            :key="header.id"
            @click="handleSort(header.column.id)"
            class="sortable-th"
          >
            <FlexRender
              :render="header.column.columnDef.header"
              :props="header.getContext()"
            />
            <!-- Aktywna strzałka sortowania -->
            <span v-if="tableState.sortBy === header.column.id">
              {{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
            </span>
          </th>
        </tr>
      </thead>

      <tbody>
        <tr v-if="table.getRowModel().rows.length === 0">
          <td
            colspan="3"
            style="text-align: center; color: #888; padding: 20px;"
          >
            Brak wyników spełniających kryteria.
          </td>
        </tr>
        <tr v-for="row in table.getRowModel().rows" :key="row.id">
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender
              :render="cell.column.columnDef.cell"
              :props="cell.getContext()"
            />
          </td>
        </tr>
      </tbody>
    </table>

    <!-- PAGINACJA -->
    <div v-if="!isLoading && !isError" class="pagination-panel">
      <button :disabled="tableState.page <= 1" @click="tableState.page--">
        Poprzednia
      </button>

      <span>Strona {{ tableState.page }} z {{ totalPages || 1 }}</span>

      <button
        :disabled="tableState.page >= totalPages"
        @click="tableState.page++"
      >
        Następna
      </button>

      <!-- Wybór limitu wierszy -->
      <select v-model="tableState.limit" @change="tableState.page = 1">
        <option :value="5">Pokazuj: 5</option>
        <option :value="10">Pokazuj: 10</option>
        <option :value="20">Pokazuj: 20</option>
      </select>
    </div>
  </div>
</template>

<style scoped>
/* Czytelny, czysty CSS bez Tailwinda */
.table-container {
  font-family: sans-serif;
  margin: 20px;
  max-width: 900px;
}
.filters-panel {
  display: flex;
  gap: 15px;
  margin-bottom: 20px;
}
.search-input {
  flex-grow: 1;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.status-select {
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}
table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 20px;
}
th,
td {
  border: 1px solid #ddd;
  padding: 12px;
  text-align: left;
}
th {
  background-color: #f4f4f4;
}
.sortable-th {
  cursor: pointer;
  user-select: none;
}
.sortable-th:hover {
  background-color: #e9e9e9;
}
tr:nth-child(even) {
  background-color: #f9f9f9;
}
.pagination-panel {
  display: flex;
  gap: 15px;
  align-items: center;
  justify-content: flex-start;
}
.pagination-panel button,
.pagination-panel select {
  padding: 8px 12px;
  cursor: pointer;
}
.pagination-panel button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}
.status-msg {
  padding: 30px;
  text-align: center;
  background: #f5f5f5;
  border-radius: 4px;
}
.status-msg.error {
  background: #ffebee;
  color: #c62828;
}
</style>

Full wypas

<script setup>
import { ref, computed } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

// 1. Jeden czysty obiekt stanu aplikacji
const tableState = ref({
  search: '',
  status: 'all', // 'all' | 'completed' | 'pending'
  page: 1,
  limit: 10,
  sortBy: 'id',
  order: 'asc',
})

// 2. Pełna logika przeniesiona do adresu URL – serwer robi całą robotę
const apiUrl = computed(() => {
  const { search, status, page, limit, sortBy, order } = tableState.value
  const skip = (page - 1) * limit

  // Budujemy parametry bazowe, które DummyJSON przyjmuje zawsze
  const baseParams = `limit=${limit}&skip=${skip}&sortBy=${sortBy}&order=${order}`

  // SCENARIUSZ A: Użytkownik wpisuje tekst w wyszukiwarkę
  if (search) {
    return `https://dummyjson.com{search}&${baseParams}`
  }

  // SCENARIUSZ B: Użytkownik filtruje po statusie (bez wpisywania tekstu)
  if (status === 'completed') {
    return `https://dummyjson.com{baseParams}`
  } else if (status === 'pending') {
    return `https://dummyjson.com{baseParams}`
  }

  // SCENARIUSZ C: Brak filtrów (Wszystkie zadania)
  return `https://dummyjson.com{baseParams}`
})

// 3. Pobieranie danych przez TanStack Query
const {
  data: apiResponse,
  isLoading,
  isError,
  error,
} = useQuery({
  queryKey: ['todos', computed(() => ({ ...tableState.value }))],
  queryFn: async () => {
    const res = await fetch(apiUrl.value)
    if (!res.ok) throw new Error('Błąd pobierania danych z serwera')
    return res.json()
  },
})

// 4. Zero lokalnego filtrowania! Bierzemy to, co dał serwer 1:1
const tableData = computed(() => apiResponse.value?.todos ?? [])

// Statystyki stron prosto z odpowiedzi serwera
const totalItems = computed(() => apiResponse.value?.total ?? 0)
const totalPages = computed(() =>
  Math.ceil(totalItems.value / tableState.value.limit),
)

// 5. Definicja kolumn (Natywny JS)
const columns = [
  { accessorKey: 'id', header: 'ID' },
  { accessorKey: 'todo', header: 'Zadanie' },
  {
    accessorKey: 'completed',
    header: 'Status',
    cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
  },
]

// 6. Tabela jako zwykła makieta HTML
const table = useVueTable({
  get data() {
    return tableData.value
  },
  columns,
  getCoreRowModel: getCoreRowModel(),
})

// Obsługa kliknięcia nagłówka (Sortowanie)
function handleSort(columnKey) {
  if (tableState.value.sortBy === columnKey) {
    tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
  } else {
    tableState.value.sortBy = columnKey
    tableState.value.order = 'asc'
  }
  tableState.value.page = 1
}

// Resetowanie do 1. strony, gdy użytkownik zmienia filtry
function resetPage() {
  tableState.value.page = 1
}
</script>

<template>
  <div class="table-container">
    <h2>Tabela z pełną obsługą po stronie serwera</h2>

    <!-- PANEL FILTRÓW -->
    <div class="filters-panel">
      <!-- Szukanie -->
      <input
        v-model="tableState.search"
        @input="resetPage"
        type="text"
        placeholder="Szukaj (np. watch, bake)..."
        class="search-input"
      />

      <!-- Filtr Statusu (Teraz steruje bezpośrednio API!) -->
      <select
        v-model="tableState.status"
        @change="resetPage"
        class="status-select"
      >
        <option value="all">Wszystkie statusy</option>
        <option value="completed">Tylko ukończone ✅</option>
        <option value="pending">W trakcie ⏳</option>
      </select>
    </div>

    <div v-if="isLoading" class="status-msg">
      Serwer przetwarza zapytanie...
    </div>
    <div v-else-if="isError" class="status-msg error">{{ error.message }}</div>

    <!-- TABELA -->
    <table v-else>
      <thead>
        <tr
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <th
            v-for="header in headerGroup.headers"
            :key="header.id"
            @click="handleSort(header.column.id)"
            class="sortable-th"
          >
            <FlexRender
              :render="header.column.columnDef.header"
              :props="header.getContext()"
            />
            <span v-if="tableState.sortBy === header.column.id">
              {{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
            </span>
          </th>
        </tr>
      </thead>

      <tbody>
        <tr v-if="table.getRowModel().rows.length === 0">
          <td
            colspan="3"
            style="text-align: center; color: #888; padding: 20px;"
          >
            Serwer nie znalazł żadnych pasujących rekordów.
          </td>
        </tr>
        <tr v-for="row in table.getRowModel().rows" :key="row.id">
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender
              :render="cell.column.columnDef.cell"
              :props="cell.getContext()"
            />
          </td>
        </tr>
      </tbody>
    </table>

    <!-- PAGINACJA -->
    <div v-if="!isLoading && !isError" class="pagination-panel">
      <button :disabled="tableState.page <= 1" @click="tableState.page--">
        Poprzednia
      </button>
      <span>Strona {{ tableState.page }} z {{ totalPages || 1 }}</span>
      <button
        :disabled="tableState.page >= totalPages"
        @click="tableState.page++"
      >
        Następna
      </button>

      <select v-model="tableState.limit" @change="resetPage">
        <option :value="5">Limit: 5</option>
        <option :value="10">Limit: 10</option>
      </select>
    </div>
  </div>
</template>

<style scoped>
.table-container {
  font-family: sans-serif;
  margin: 20px;
  max-width: 900px;
}
.filters-panel {
  display: flex;
  gap: 15px;
  margin-bottom: 20px;
}
.search-input {
  flex-grow: 1;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.status-select {
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  cursor: pointer;
}
table {
  width: 100%;
  border-collapse: collapse;
  margin-bottom: 20px;
}
th,
td {
  border: 1px solid #ddd;
  padding: 12px;
  text-align: left;
}
th {
  background-color: #f4f4f4;
}
.sortable-th {
  cursor: pointer;
  user-select: none;
}
.sortable-th:hover {
  background-color: #e9e9e9;
}
tr:nth-child(even) {
  background-color: #f9f9f9;
}
.pagination-panel {
  display: flex;
  gap: 15px;
  align-items: center;
}
.pagination-panel button,
.pagination-panel select {
  padding: 8px 12px;
  cursor: pointer;
}
.pagination-panel button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}
.status-msg {
  padding: 30px;
  text-align: center;
  background: #f5f5f5;
  border-radius: 4px;
}
</style>

useRouter.js

import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuery } from '@tanstack/vue-query'

const route = useRoute()
const router = useRouter()

// 1. ODCZYT: Tworzymy reaktywny tableState powiązany z paskiem adresu URL
const tableState = computed({
  get() {
    return {
      search: route.query.search || '',
      status: route.query.status || 'all',
      page: Number(route.query.page) || 1,
      limit: Number(route.query.limit) || 10,
      sortBy: route.query.sortBy || 'id',
      order: route.query.order || 'asc',
    }
  },
  set(newValue) {
    // 2. ZAPIS: Kiedy zmieniamy jakąś wartość, wrzucamy ją do paska adresu URL
    router.push({
      query: {
        // Czyścimy puste wartości, żeby nie śmiecić w URL (np. ?search= gdy jest puste)
        search: newValue.search || undefined,
        status: newValue.status !== 'all' ? newValue.status : undefined,
        page: newValue.page > 1 ? newValue.page : undefined,
        limit: newValue.limit !== 10 ? newValue.limit : undefined,
        sortBy: newValue.sortBy !== 'id' ? newValue.sortBy : undefined,
        order: newValue.order !== 'asc' ? newValue.order : undefined,
      },
    })
  },
})

// Funkcja pomocnicza do aktualizacji pojedynczego filtra
function updateState(key, value) {
  const currentState = { ...tableState.value }
  currentState[key] = value

  // Jeśli zmieniamy filtr lub szukanie, resetujemy stronę do 1
  if (key === 'search' || key === 'status') {
    currentState.page = 1
  }

  tableState.value = currentState // Wywoła funkcję set() i zmieni URL w przeglądarce
}

Komponemt

<input
  :value="tableState.search"
  @input="updateState('search', $event.target.value)"
  type="text"
  placeholder="Szukaj..."
/>

<button
  :disabled="tableState.page <= 1"
  @click="updateState('page', tableState.page - 1)"
>
  Poprzednia
</button>

Links

https://tanstack.com/query/latest/docs/framework/vue/quick-start
https://tanstack.com/query/latest/docs/framework/vue/examples/basic
https://tanstack.com/table/latest/docs/framework/vue/examples/with-tanstack-query

Laravel Inertia Server Side

BACKEND: Prosty i czysty kontroler Laravela

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Inertia\Inertia;

class TodoController extends Controller
{
    public function index(Request $request)
    {
        $query = Todo::query();

        if ($request->filled('search')) {
            $query->where('todo', 'like', '%' . $request->search . '%');
        }

        if ($request->filled('status') && $request->status !== 'all') {
            $query->where('completed', $request->status === 'completed');
        }

        $sortBy = $request->input('sortBy', 'id');
        $order = $request->input('order', 'asc');
        $query->orderBy($sortBy, $order);

        $limit = $request->input('limit', 10);
        $todos = $query->paginate($limit)->withQueryString();

        return Inertia::render('Todos/Index', [
            'todos' => $todos,
            'filters' => $request->only(['search', 'status', 'sortBy', 'order', 'limit'])
        ]);
    }
}

FRONTEND: Komponent Vue 3 (Index.vue)

Przykład z h

<script setup>
import { ref, computed, h } from 'vue' // Importujemy funkcję h
import { router } from '@inertiajs/vue3'
import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

const props = defineProps({
  todos: Object,
  filters: Object
})

const tableState = ref({
  search: props.filters.search || '',
  status: props.filters.status || 'all',
  sortBy: props.filters.sortBy || 'id',
  order: props.filters.order || 'asc',
  limit: props.filters.limit || 10
})

const rowSelection = ref({})

// 1. DEFINICJA KOLUMN Z UŻYCIEM FUNKCJI h()
const columns = [
  {
    id: 'select',
    // Nagłówek: funkcja h() tworzy zwykły input typu checkbox i podpina zdarzenie onChange
    header: ({ table }) => {
      const hasRows = table.getRowModel().rows.length > 0
      return h('input', {
        type: 'checkbox',
        class: 'table-checkbox',
        checked: hasRows && table.getIsAllPageRowsSelected(),
        // W czystym h() atrybut indeterminate przekazujemy w sekcji 'prop' (właściwości DOM)
        domProps: {
          indeterminate: hasRows && table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
        },
        onChange: (event) => table.getToggleAllPageRowsSelectedHandler()(event)
      })
    },
    // Komórka wiersza: funkcja h() tworzy pojedynczy checkbox dla każdego todo
    cell: ({ row }) => {
      return h('input', {
        type: 'checkbox',
        class: 'table-checkbox',
        checked: row.getIsSelected(),
        disabled: !row.getCanSelect(),
        onChange: (event) => row.getToggleSelectedHandler()(event)
      })
    }
  },
  { accessorKey: 'id', header: 'ID' },
  { accessorKey: 'todo', header: 'Zadanie' },
  {
    accessorKey: 'completed',
    header: 'Status',
    cell: (info) => info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'
  }
]

// Konfiguracja TanStack Table
const table = useVueTable({
  get data() { return props.todos.data },
  columns,
  state: {
    get rowSelection() { return rowSelection.value }
  },
  enableRowSelection: true,
  onRowSelectionChange: (updater) => {
    rowSelection.value = typeof updater === 'function' ? updater(rowSelection.value) : updater
  },
  getCoreRowModel: getCoreRowModel()
})

const selectedTodoIds = computed(() => {
  return table.getSelectedRowModel().rows.map(row => row.original.id)
})

function updateTable(additionalParams = {}) {
  router.get('/todos', { ...tableState.value, ...additionalParams }, { preserveState: true, preserveScroll: true, replace: true })
}

function onFilterChange() {
  rowSelection.value = {}
  updateTable({ page: 1 })
}

function handleSort(columnKey) {
  if (columnKey === 'select') return
  if (tableState.value.sortBy === columnKey) {
    tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
  } else {
    tableState.value.sortBy = columnKey
    tableState.value.order = 'asc'
  }
  updateTable({ page: 1 })
}
</script>

<template>
  <div class="table-container">
    <h2>Laravel + Inertia + TanStack (Zaznaczanie z funkcją h())</h2>

    <div class="filters-panel">
      <input v-model="tableState.search" @input="onFilterChange" type="text" placeholder="Szukaj..." class="search-input" />
      <select v-model="tableState.status" @change="onFilterChange" class="status-select">
        <option value="all">Wszystkie</option>
        <option value="completed">Ukończone</option>
        <option value="pending">W trakcie</option>
      </select>

      <button v-if="selectedTodoIds.length > 0" class="danger-btn">
        Usuń zaznaczone ({{ selectedTodoIds.length }})
      </button>
    </div>

    <!-- TABELA HTML (Teraz czysta, bez nasłuchiwania @change na poziomie tabeli!) -->
    <table>
      <thead>
        <tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
          <th
            v-for="header in headerGroup.headers"
            :key="header.id"
            @click="handleSort(header.column.id)"
            :class="{ 'sortable': header.column.id !== 'select' }"
          >
            <!-- FlexRender natywnie obsługuje obiekty wygenerowane przez funkcję h() -->
            <FlexRender :render="header.column.columnDef.header" :props="header.getContext()" />
            <span v-if="tableState.sortBy === header.column.id && header.column.id !== 'select'">
              {{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
            </span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-if="props.todos.data.length === 0">
          <td colspan="4" style="text-align: center; color: #888; padding: 20px;">Brak wyników.</td>
        </tr>

        <tr
          v-for="row in table.getRowModel().rows"
          :key="row.id"
          :class="{ 'selected-row': row.getIsSelected() }"
        >
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
          </td>
        </tr>
      </tbody>
    </table>

    <!-- PAGINACJA -->
    <div class="pagination-panel">
      <button :disabled="!props.todos.prev_page_url" @click="router.get(props.todos.prev_page_url, {}, { preserveScroll: true, preserveState: true })">Poprzednia</button>
      <span>Strona {{ props.todos.current_page }} z {{ props.todos.last_page }}</span>
      <button :disabled="!props.todos.next_page_url" @click="router.get(props.todos.next_page_url, {}, { preserveScroll: true, preserveState: true })">Następna</button>
    </div>
  </div>
</script>

<style scoped>
/* Stylizacja bez zmian... */
.table-container { font-family: sans-serif; margin: 20px; max-width: 900px; }
.filters-panel { display: flex; gap: 15px; margin-bottom: 20px; align-items: center; }
.search-input { flex-grow: 1; padding: 10px; border: 1px solid #ccc; border-radius: 4px; }
.status-select { padding: 10px; border: 1px solid #ccc; border-radius: 4px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
th { background-color: #f4f4f4; }
.sortable { cursor: pointer; user-select: none; }
.sortable:hover { background-color: #e9e9e9; }
tr:nth-child(even) { background-color: #f9f9f9; }
.selected-row { background-color: #e3f2fd !important; }
.table-checkbox { transform: scale(1.2); cursor: pointer; }
.pagination-panel { display: flex; gap: 15px; align-items: center; }
.pagination-panel button { padding: 8px 12px; cursor: pointer; }
.danger-btn { padding: 10px 15px; background-color: #d32f2f; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; }
</style>

Przykład bez h

<script setup>
  import { ref, computed } from 'vue'
  import { router } from '@inertiajs/vue3'
  import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

  // 1. Odbieramy gotowe dane i filtry bezpośrednio z Laravela
  const props = defineProps({
    todos: Object, // Zawiera przefiltrowane wiersze z bazy (todos.data) oraz paginację
    filters: Object, // Zawiera aktualny stan filtrów z URL
  })

  // 2. Jeden obiekt stanu zsynchronizowany z propsami
  const tableState = ref({
    search: props.filters.search || '',
    status: props.filters.status || 'all',
    sortBy: props.filters.sortBy || 'id',
    order: props.filters.order || 'asc',
    limit: props.filters.limit || 10,
  })

  // 3. TANSTACK TABLE: Karmimy tabelę danymi bezpośrednio z Laravel Props!
  // ZerouseQuery, zero asynchronicznych fetchów w komponencie.
  const columns = [
    { accessorKey: 'id', header: 'ID' },
    { accessorKey: 'todo', header: 'Zadanie' },
    {
      accessorKey: 'completed',
      header: 'Status',
      cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
    },
  ]

  const table = useVueTable({
    // Tabela reaguje bezpośrednio na to, co przysłał serwer do propsów
    get data() {
      return props.todos.data
    },
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  // 4. JEDNA funkcja do aktualizacji paska URL i pobierania świeżych danych
  function updateTable(additionalParams = {}) {
    router.get(
      '/todos',
      { ...tableState.value, ...additionalParams },
      { preserveState: true, preserveScroll: true, replace: true },
    )
  }

  // Obsługa interakcji użytkownika
  function onFilterChange() {
    updateTable({ page: 1 }) // reset do pierwszej strony przy zmianie filtrów
  }

  function handleSort(columnKey) {
    if (tableState.value.sortBy === columnKey) {
      tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
    } else {
      tableState.value.sortBy = columnKey
      tableState.value.order = 'asc'
    }
    updateTable({ page: 1 })
  }
</script>

<template>
  <div class="table-container">
    <h2>Prawidłowy wzorzec: Laravel + Inertia + TanStack (Czysty JS)</h2>

    <!-- FILTRY -->
    <div class="filters-panel">
      <input
        v-model="tableState.search"
        @input="onFilterChange"
        type="text"
        placeholder="Szukaj zadania..."
        class="search-input"
      />

      <select
        v-model="tableState.status"
        @change="onFilterChange"
        class="status-select"
      >
        <option value="all">Wszystkie statusy</option>
        <option value="completed">Ukończone</option>
        <option value="pending">W trakcie</option>
      </select>
    </div>

    <!-- TABELA (Rysowana przez TanStack na bazie propsów) -->
    <table>
      <thead>
        <tr
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <th
            v-for="header in headerGroup.headers"
            :key="header.id"
            @click="handleSort(header.column.id)"
            class="sortable"
          >
            <FlexRender
              :render="header.column.columnDef.header"
              :props="header.getContext()"
            />
            <span v-if="tableState.sortBy === header.column.id">
              {{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
            </span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-if="props.todos.data.length === 0">
          <td
            colspan="3"
            style="text-align: center; color: #888; padding: 20px;"
          >
            Brak wyników w bazie danych.
          </td>
        </tr>
        <tr v-for="row in table.getRowModel().rows" :key="row.id">
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender
              :render="cell.column.columnDef.cell"
              :props="cell.getContext()"
            />
          </td>
        </tr>
      </tbody>
    </table>

    <!-- PAGINACJA (Korzysta z natywnych linków i stanu paginatora Laravela) -->
    <div class="pagination-panel">
      <button
        :disabled="!props.todos.prev_page_url"
        @click="router.get(props.todos.prev_page_url, {}, { preserveScroll: true, preserveState: true })"
      >
        Poprzednia
      </button>

      <span
        >Strona {{ props.todos.current_page }} z {{ props.todos.last_page
        }}</span
      >

      <button
        :disabled="!props.todos.next_page_url"
        @click="router.get(props.todos.next_page_url, {}, { preserveScroll: true, preserveState: true })"
      >
        Następna
      </button>

      <select
        v-model="tableState.limit"
        @change="onFilterChange"
        class="limit-select"
      >
        <option :value="5">Limit: 5</option>
        <option :value="10">Limit: 10</option>
        <option :value="20">Limit: 20</option>
      </select>
    </div>
  </div>
</template>

<style scoped>
  .table-container {
    font-family: sans-serif;
    margin: 20px;
    max-width: 900px;
  }
  .filters-panel {
    display: flex;
    gap: 15px;
    margin-bottom: 20px;
  }
  .search-input {
    flex-grow: 1;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
  .status-select,
  .limit-select {
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
  table {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: 20px;
  }
  th,
  td {
    border: 1px solid #ddd;
    padding: 12px;
    text-align: left;
  }
  th {
    background-color: #f4f4f4;
  }
  .sortable {
    cursor: pointer;
    user-select: none;
  }
  .sortable:hover {
    background-color: #e9e9e9;
  }
  tr:nth-child(even) {
    background-color: #f9f9f9;
  }
  .pagination-panel {
    display: flex;
    gap: 15px;
    align-items: center;
  }
  .pagination-panel button {
    padding: 8px 12px;
    cursor: pointer;
  }
</style>

Przykład z zaznaczaniem wierszy

<script setup>
  import { ref, computed } from 'vue'
  import { router } from '@inertiajs/vue3'
  import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

  const props = defineProps({
    todos: Object,
    filters: Object,
  })

  const tableState = ref({
    search: props.filters.search || '',
    status: props.filters.status || 'all',
    sortBy: props.filters.sortBy || 'id',
    order: props.filters.order || 'asc',
    limit: props.filters.limit || 10,
  })

  // 1. REAKTYWNY STAN DLA ZAZNACZONYCH WIERSZY
  // TanStack przechowuje to w formacie obiektu: { "id_wiersza": true }
  const rowSelection = ref({})

  // 2. NOWA KOLUMNA Z CHECKBOXEM NA POCZĄTKU LISTY
  const columns = [
    {
      id: 'select',
      // Nagłówek tabeli (Przycisk "Zaznacz wszystkie" na danej stronie)
      header: ({ table }) => {
        // Bezpiecznie sprawdzamy, czy wiersze z propsów w ogóle już są załadowane
        const hasRows = table.getRowModel().rows.length > 0
        return `<input
            type="checkbox"
            ${hasRows && table.getIsAllPageRowsSelected() ? 'checked' : ''}
            ${hasRows && table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected() ? 'indeterminate="true"' : ''}
            class="table-checkbox header-checkbox"
          />`
      },
      // Komórka wiersza (Checkbox dla konkretnego wiersza)
      cell: ({ row }) => {
        return `<input
            type="checkbox"
            ${row.getIsSelected() ? 'checked' : ''}
            ${!row.getCanSelect() ? 'disabled' : ''}
            class="table-checkbox row-checkbox"
          />`
      },
    },
    { accessorKey: 'id', header: 'ID' },
    { accessorKey: 'todo', header: 'Zadanie' },
    {
      accessorKey: 'completed',
      header: 'Status',
      cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
    },
  ]

  // 3. ROZBUDOWANA KONFIGURACJA TANSTACK TABLE
  const table = useVueTable({
    get data() {
      return props.todos.data
    },
    columns,
    state: {
      // Przekazujemy stan zaznaczenia do TanStacka
      get rowSelection() {
        return rowSelection.value
      },
    },
    // Włączamy obsługę zaznaczania wierszy
    enableRowSelection: true,
    // Funkcja, która aktualizuje nasz ref, gdy użytkownik coś kliknie
    onRowSelectionChange: (updater) => {
      rowSelection.value =
        typeof updater === 'function' ? updater(rowSelection.value) : updater
    },
    getCoreRowModel: getCoreRowModel(),
  })

  // 4. OBSŁUGA KLIKNIĘĆ W CHECKBOXY (Przechwytujemy zdarzenia z surowego HTML)
  function handleCheckboxClick(event) {
    const target = event.target

    // Jeśli kliknięto checkbox w nagłówku (Zaznacz wszystkie)
    if (target.classList.contains('header-checkbox')) {
      table.getToggleAllPageRowsSelectedHandler()(event)
    }

    // Jeśli kliknięto checkbox w konkretnym wierszu
    if (target.classList.contains('row-checkbox')) {
      // Szukamy najbliższego wiersza TR, aby dowiedzieć się, który to indeks TanStacka
      const tr = target.closest('tr')
      if (tr && tr.dataset.rowIndex !== undefined) {
        const rowIndex = Number(tr.dataset.rowIndex)
        const row = table.getRowModel().rows[rowIndex]
        if (row) {
          row.getToggleSelectedHandler()(event)
        }
      }
    }
  }

  // 5. WYCIĄGANIE IDENTYFIKATORÓW ZAZNACZONYCH ELEMENTÓW
  // Przydatne, gdy chcesz wysłać zaznaczone ID do Laravela (np. do masowego usuwania)
  const selectedTodoIds = computed(() => {
    return table.getSelectedRowModel().rows.map((row) => row.original.id)
  })

  // Przykładowa akcja masowa (np. usuwanie)
  function deleteSelected() {
    if (
      confirm(
        `Czy na pewno chcesz usunąć zaznaczone zadania (ID: ${selectedTodoIds.value.join(', ')})?`,
      )
    ) {
      router.delete('/todos/mass-destroy', {
        data: { ids: selectedTodoIds.value },
        onSuccess: () => {
          rowSelection.value = {} // resetujemy zaznaczenie po udanym usunięciu
        },
      })
    }
  }

  // Reszta logiki Inertia bez zmian...
  function updateTable(additionalParams = {}) {
    router.get(
      '/todos',
      { ...tableState.value, ...additionalParams },
      { preserveState: true, preserveScroll: true, replace: true },
    )
  }

  function onFilterChange() {
    rowSelection.value = {} // Dobra praktyka: czyścimy zaznaczenie przy zmianie filtrów/wyszukiwania
    updateTable({ page: 1 })
  }

  function handleSort(columnKey) {
    if (columnKey === 'select') return // ignorujemy sortowanie po checkboxie
    if (tableState.value.sortBy === columnKey) {
      tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
    } else {
      tableState.value.sortBy = columnKey
      tableState.value.order = 'asc'
    }
    updateTable({ page: 1 })
  }

  // Przykład
  // h(DropdownMenu, {}, {
  //   default: () => [
  //     h(DropdownMenuTrigger, ...),
  //     h(DropdownMenuContent, ...)
  //   ]
  // })
</script>

<template>
  <div class="table-container">
    <h2>Laravel + Inertia + TanStack Row Selection (Czysty JS)</h2>

    <!-- PANEL FILTRÓW I AKCJI MASOWYCH -->
    <div class="filters-panel">
      <input
        v-model="tableState.search"
        @input="onFilterChange"
        type="text"
        placeholder="Szukaj..."
        class="search-input"
      />
      <select
        v-model="tableState.status"
        @change="onFilterChange"
        class="status-select"
      >
        <option value="all">Wszystkie</option>
        <option value="completed">Ukończone</option>
        <option value="pending">W trakcie</option>
      </select>

      <!-- Przycisk akcji masowej pojawia się tylko, gdy coś zaznaczono -->
      <button
        v-if="selectedTodoIds.length > 0"
        @click="deleteSelected"
        class="danger-btn"
      >
        Usuń zaznaczone ({{ selectedTodoIds.length }})
      </button>
    </div>

    <!-- TABELA HTML -->
    <!-- Podpinamy @change pod całą tabelę (Event Delegation), aby obsłużyć checkboxy wyrenderowane przez FlexRender -->
    <table @change="handleCheckboxClick">
      <thead>
        <tr
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <th
            v-for="header in headerGroup.headers"
            :key="header.id"
            @click="handleSort(header.column.id)"
            :class="{ 'sortable': header.column.id !== 'select' }"
          >
            <FlexRender
              :render="header.column.columnDef.header"
              :props="header.getContext()"
            />
            <span
              v-if="tableState.sortBy === header.column.id && header.column.id !== 'select'"
            >
              {{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
            </span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-if="props.todos.data.length === 0">
          <td
            colspan="4"
            style="text-align: center; color: #888; padding: 20px;"
          >
            Brak wyników.
          </td>
        </tr>

        <!-- Dodajemy :data-row-index, aby funkcja kliknięcia wiedziała, który wiersz zaznaczono -->
        <tr
          v-for="(row, index) in table.getRowModel().rows"
          :key="row.id"
          :data-row-index="index"
          :class="{ 'selected-row': row.getIsSelected() }"
        >
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender
              :render="cell.column.columnDef.cell"
              :props="cell.getContext()"
            />
          </td>
        </tr>
      </tbody>
    </table>

    <!-- PAGINACJA -->
    <div class="pagination-panel">
      <button
        :disabled="!props.todos.prev_page_url"
        @click="router.get(props.todos.prev_page_url, {}, { preserveScroll: true, preserveState: true })"
      >
        Poprzednia
      </button>
      <span
        >Strona {{ props.todos.current_page }} z {{ props.todos.last_page
        }}</span
      >
      <button
        :disabled="!props.todos.next_page_url"
        @click="router.get(props.todos.next_page_url, {}, { preserveScroll: true, preserveState: true })"
      >
        Następna
      </button>

      <select v-model="tableState.limit" @change="onFilterChange">
        <option :value="5">Limit: 5</option>
        <option :value="10">Limit: 10</option>
      </select>
    </div>
  </div>
</template>

<style scoped>
  .table-container {
    font-family: sans-serif;
    margin: 20px;
    max-width: 900px;
  }
  .filters-panel {
    display: flex;
    gap: 15px;
    margin-bottom: 20px;
    align-items: center;
  }
  .search-input {
    flex-grow: 1;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
  .status-select {
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
  table {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: 20px;
  }
  th,
  td {
    border: 1px solid #ddd;
    padding: 12px;
    text-align: left;
  }
  th {
    background-color: #f4f4f4;
  }
  .sortable {
    cursor: pointer;
    user-select: none;
  }
  .sortable:hover {
    background-color: #e9e9e9;
  }
  tr:nth-child(even) {
    background-color: #f9f9f9;
  }
  .selected-row {
    background-color: #e3f2fd !important;
  } /* podświetlenie zaznaczonego wiersza */
  .table-checkbox {
    transform: scale(1.2);
    cursor: pointer;
  }
  .pagination-panel {
    display: flex;
    gap: 15px;
    align-items: center;
  }
  .pagination-panel button {
    padding: 8px 12px;
    cursor: pointer;
  }
  .danger-btn {
    padding: 10px 15px;
    background-color: #d32f2f;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
  }
  .danger-btn:hover {
    background-color: #b71c1c;
  }
</style>

Kod komponentu Vue 3 (Index.vue) Shadcn

<script setup>
  import { ref, computed, h } from 'vue'
  import { router } from '@inertiajs/vue3'
  import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

  // 1. IMPORTUJEMY KOMPONENTY (np. z Twojego folderu shadcn/ui)
  // Jeśli nie masz shadcn, mogą to być jakiekolwiek inne komponenty Vue
  import { Button } from '@/components/ui/button'
  import {
    DropdownMenu,
    DropdownMenuContent,
    DropdownMenuItem,
    DropdownMenuTrigger,
  } from '@/components/ui/dropdown-menu'

  // Ikona trzech kropek (możesz użyć lucide-vue-next lub zwykłego tekstu)
  // Tutaj użyjemy zwykłego wielokropka "⋯" dla uproszczenia przykładu bez dodatkowych paczek
  const MoreHorizontalIcon = () => h('span', { class: 'text-lg' }, '⋯')

  const props = defineProps({
    todos: Object,
    filters: Object,
  })

  const tableState = ref({
    search: props.filters.search || '',
    status: props.filters.status || 'all',
    sortBy: props.filters.sortBy || 'id',
    order: props.filters.order || 'asc',
    limit: props.filters.limit || 10,
  })

  const rowSelection = ref({})

  // Metody dla akcji wiersza
  function editTodo(id) {
    // Przekierowanie Inertii na stronę edycji
    router.get(`/todos/${id}/edit`)
  }

  function deleteTodo(id) {
    if (confirm(`Czy na pewno usunąć zadanie o ID ${id}?`)) {
      router.delete(`/todos/${id}`)
    }
  }

  // 2. ROZBUDOWANA DEFINICJA KOLUMN Z KOMPONENTAMI SHADCN
  const columns = [
    // Kolumna z checkboxem (zostaje z poprzedniego kroku, też używa h())
    {
      id: 'select',
      header: ({ table }) => {
        const hasRows = table.getRowModel().rows.length > 0
        return h('input', {
          type: 'checkbox',
          checked: hasRows && table.getIsAllPageRowsSelected(),
          domProps: {
            indeterminate:
              hasRows &&
              table.getIsSomePageRowsSelected() &&
              !table.getIsAllPageRowsSelected(),
          },
          onChange: (event) =>
            table.getToggleAllPageRowsSelectedHandler()(event),
        })
      },
      cell: ({ row }) =>
        h('input', {
          type: 'checkbox',
          checked: row.getIsSelected(),
          disabled: !row.getCanSelect(),
          onChange: (event) => row.getToggleSelectedHandler()(event),
        }),
    },
    { accessorKey: 'id', header: 'ID' },
    { accessorKey: 'todo', header: 'Zadanie' },

    // 3. NOWA KOLUMNA: AKCJE (Dropdown z shadcn)
    {
      id: 'actions',
      header: '', // brak tekstu w nagłówku kolumny akcji
      cell: ({ row }) => {
        // Wyciągamy surowy obiekt danych z bazy dla tego wiersza
        const todo = row.original

        // W czystym JS funkcja h() tworzy strukturę komponentów przez zagnieżdżanie:
        return h(
          DropdownMenu,
          {},
          {
            // Slot domyślny (lub nazwany) w shadcn przekazujemy jako funkcje zwracające h()
            default: () => [
              // Trigger: przycisk wywołujący menu
              h(
                DropdownMenuTrigger,
                { asChild: true },
                {
                  default: () =>
                    h(
                      Button,
                      { variant: 'ghost', class: 'h-8 w-8 p-0' },
                      {
                        default: () => [
                          h('span', { class: 'sr-only' }, 'Otwórz menu'),
                          h(MoreHorizontalIcon),
                        ],
                      },
                    ),
                },
              ),
              // Content: zawartość menu (lista opcji)
              h(
                DropdownMenuContent,
                { align: 'end' },
                {
                  default: () => [
                    // Opcja 1: Edytuj
                    h(
                      DropdownMenuItem,
                      {
                        onSelect: () => editTodo(todo.id),
                      },
                      { default: () => 'Edytuj zadanie' },
                    ),

                    // Opcja 2: Usuń (z czerwoną klasą destruktywną)
                    h(
                      DropdownMenuItem,
                      {
                        class: 'text-red-600 focus:text-red-600',
                        onSelect: () => deleteTodo(todo.id),
                      },
                      { default: () => 'Usuń' },
                    ),
                  ],
                },
              ),
            ],
          },
        )
      },
    },
  ]

  // Konfiguracja tabeli
  const table = useVueTable({
    get data() {
      return props.todos.data
    },
    columns,
    state: {
      get rowSelection() {
        return rowSelection.value
      },
    },
    enableRowSelection: true,
    onRowSelectionChange: (updater) => {
      rowSelection.value =
        typeof updater === 'function' ? updater(rowSelection.value) : updater
    },
    getCoreRowModel: getCoreRowModel(),
  })

  // Reszta metod Inertii bez zmian...
  function updateTable(additionalParams = {}) {
    router.get(
      '/todos',
      { ...tableState.value, ...additionalParams },
      { preserveState: true, preserveScroll: true, replace: true },
    )
  }
  function handleSort(columnKey) {
    if (['select', 'actions'].includes(columnKey)) return
    if (tableState.value.sortBy === columnKey) {
      tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
    } else {
      tableState.value.sortBy = columnKey
      tableState.value.order = 'asc'
    }
    updateTable({ page: 1 })
  }
</script>

<template>
  <div class="table-container">
    <table>
      <thead>
        <tr
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <th
            v-for="header in headerGroup.headers"
            :key="header.id"
            @click="handleSort(header.column.id)"
            :class="{ 'sortable': !['select', 'actions'].includes(header.column.id) }"
          >
            <FlexRender
              :render="header.column.columnDef.header"
              :props="header.getContext()"
            />
            <span
              v-if="tableState.sortBy === header.column.id && !['select', 'actions'].includes(header.column.id)"
            >
              {{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
            </span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr
          v-for="row in table.getRowModel().rows"
          :key="row.id"
          :class="{ 'selected-row': row.getIsSelected() }"
        >
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender
              :render="cell.column.columnDef.cell"
              :props="cell.getContext()"
            />
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<style scoped>
  /* Te same style CSS co poprzednio... */
  table {
    width: 100%;
    border-collapse: collapse;
  }
  th,
  td {
    border: 1px solid #ddd;
    padding: 12px;
    text-align: left;
  }
  th {
    background-color: #f4f4f4;
  }
  .sortable {
    cursor: pointer;
    user-select: none;
  }
  .sortable:hover {
    background-color: #e9e9e9;
  }
  .selected-row {
    background-color: #e3f2fd !important;
  }
</style>

Multiple delete

BACKEND: Trasa w Laravel (web.php)

use App\Http\Controllers\TodoController;
use Illuminate\Support\Facades\Route;
Route::delete('/todos/mass-destroy', [TodoController::class, 'massDestroy'])->name('todos.mass-destroy');

BACKEND: Kontroler Laravela (TodoController.php)

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;

class TodoController extends Controller
{
    // ... poprzednie metody (index, etc.)

    public function massDestroy(Request $request)
    {
        // 1. Walidacja: sprawdzamy czy 'ids' to tablica i czy te ID istnieją w tabeli 'todos'
        $validated = $request->validate([
            'ids' => 'required|array',
            'ids.*' => 'exists:todos,id',
        ]);

        // 2. Bezpieczne masowe usunięcie jednym zapytaniem SQL
        Todo::whereIn('id', $validated['ids'])->delete();

        // 3. Przekierowanie powrotne (Inertia automatycznie odświeży dane w tabeli)
        return redirect()->back()->with('message', 'Pomyślnie usunięto zaznaczone zadania.');
    }
}

FRONTEND: Komponent Vue (Index.vue)

<script setup>
  import { ref, computed, h } from 'vue'
  import { router } from '@inertiajs/vue3'
  import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

  // ... konfiguracja stanów, kolumn i TanStack Table z poprzednich kroków

  // Wyciągamy tablicę z ID zaznaczonych elementów z modelu TanStacka
  const selectedTodoIds = computed(() => {
    return table.getSelectedRowModel().rows.map((row) => row.original.id)
  })

  // FUNKCJA MASOWEGO USUWANIA
  function deleteSelected() {
    if (selectedTodoIds.value.length === 0) return

    if (
      confirm(
        `Czy na pewno chcesz usunąć te zadania? (ID: ${selectedTodoIds.value.join(', ')})`,
      )
    ) {
      router.delete('/todos/mass-destroy', {
        // Przekazujemy tablicę z ID w sekcji 'data'
        data: {
          ids: selectedTodoIds.value,
        },
        preserveScroll: true, // Zapobiega skakaniu strony w górę po usunięciu
        onSuccess: () => {
          // Czyścimy zaznaczenie w tabeli po udanym usunięciu z bazy
          rowSelection.value = {}
        },
      })
    }
  }
</script>

<template>
  <div class="table-container">
    <!-- Dynamiczny przycisk masowego usuwania w panelu filtrów -->
    <div class="filters-panel">
      <!-- ... wyszukiwarka i statusy ... -->

      <!-- Przycisk pojawia się w DOM tylko, gdy użytkownik zaznaczy przynajmniej 1 wiersz -->
      <button
        v-if="selectedTodoIds.length > 0"
        @click="deleteSelected"
        class="danger-btn"
      >
        Usuń zaznaczone ({{ selectedTodoIds.length }})
      </button>
    </div>

    <!-- ... kod tabeli HTML i paginacji ... -->
  </div>
</template>

<style scoped>
  .danger-btn {
    padding: 10px 15px;
    background-color: #ef4444; /* Czerwony kolor destruktywny */
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-weight: bold;
    transition: background-color 0.2s;
  }
  .danger-btn:hover {
    background-color: #dc2626;
  }
</style>

Laravel Inertia Server Side + API

BACKEND Kontroler Laravela (TodoController.php)

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Inertia\Inertia;

class TodoController extends Controller
{
    public function index(Request $request)
    {
        // 1. Pobieramy aktualne filtry z paska URL
        $filters = $request->only(['search', 'status', 'page', 'limit', 'sortBy', 'order']);

        // 2. Jeśli zapytanie chce czysty JSON (od TanStack Query)
        if ($request->wantsJson()) {
            $query = Todo::query();

            if ($request->filled('search')) {
                $query->where('todo', 'like', '%' . $request->search . '%');
            }

            if ($request->filled('status') && $request->status !== 'all') {
                $query->where('completed', $request->status === 'completed');
            }

            $sortBy = $request->input('sortBy', 'id');
            $order = $request->input('order', 'asc');
            $query->orderBy($sortBy, $order);

            // Zwracamy paginację jako czysty JSON dla useQuery
            return response()->json($query->paginate($request->input('limit', 10)));
        }

        // 3. Pierwsze wejście na stronę: renderujemy widok Inertii i przekazujemy filtry z URL do props
        return Inertia::render('Todos/IndexWithTanstack', [
            'filters' => $filters
        ]);
    }
}

FRONTEND: Komponent Vue 3 (IndexWithTanstack.vue)

<script setup>
  import { ref, computed, watch } from 'vue'
  import { router } from '@inertiajs/vue3'
  import { useQuery } from '@tanstack/vue-query'
  import { useVueTable, getCoreRowModel, FlexRender } from '@tanstack/vue-table'

  // Pobieramy filtry z Laravela na starcie strony
  const props = defineProps({
    filters: Object,
  })

  // 1. Inicjalizacja stanu na bazie props.filters z paska adresu URL
  const tableState = ref({
    search: props.filters.search || '',
    status: props.filters.status || 'all',
    page: Number(props.filters.page) || 1,
    limit: Number(props.filters.limit) || 10,
    sortBy: props.filters.sortBy || 'id',
    order: props.filters.order || 'asc',
  })

  // 2. Budujemy adres URL dla TanStack Query
  const apiFetchUrl = computed(() => {
    const { search, status, page, limit, sortBy, order } = tableState.value
    return `/todos?search=${search}&status=${status}&page=${page}&limit=${limit}&sortBy=${sortBy}&order=${order}`
  })

  // 3. TanStack Query pobiera czyste dane JSON z Laravela
  const { data: apiResponse, isLoading } = useQuery({
    queryKey: ['todos', computed(() => ({ ...tableState.value }))],
    queryFn: async () => {
      // Nagłówek 'X-Requested-With' mówi Laravelowi, że chcemy czysty JSON (wantsJson())
      const res = await fetch(apiFetchUrl.value, {
        headers: { 'X-Requested-With': 'XMLHttpRequest' },
      })
      return res.json()
    },
  })

  // Dane dla TanStack Table
  const tableData = computed(() => apiResponse.value?.data || [])
  const totalPages = computed(() => apiResponse.value?.last_page || 1)

  // 4. Definicja kolumn TanStack Table (Czysty JS)
  const columns = [
    { accessorKey: 'id', header: 'ID' },
    { accessorKey: 'todo', header: 'Zadanie' },
    {
      accessorKey: 'completed',
      header: 'Status',
      cell: (info) => (info.getValue() ? '✅ Ukończone' : '⏳ W trakcie'),
    },
  ]

  const table = useVueTable({
    get data() {
      return tableData.value
    },
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

  // 5. SYNCHRONIZACJA Z PASKIEM ADRESU URL PRZEZ INERTIA
  // Zamiast wysyłać zapytanie fetch, aktualizujemy pasek adresu.
  function syncWithUrl() {
    router.get(
      '/todos',
      { ...tableState.value },
      { preserveState: true, preserveScroll: true, replace: true },
    )
  }

  // Obsługa zmian w filtrach
  function onFilterChange() {
    tableState.value.page = 1 // reset do 1 strony
    syncWithUrl()
  }

  function handleSort(columnKey) {
    if (tableState.value.sortBy === columnKey) {
      tableState.value.order = tableState.value.order === 'asc' ? 'desc' : 'asc'
    } else {
      tableState.value.sortBy = columnKey
      tableState.value.order = 'asc'
    }
    tableState.value.page = 1
    syncWithUrl()
  }

  function changePage(newPage) {
    tableState.value.page = newPage
    syncWithUrl()
  }
</script>

<template>
  <div class="table-container">
    <h2>TanStack Table + Query wewnątrz Inertia.js (Czysty JS)</h2>

    <!-- FILTRY -->
    <div class="filters-panel">
      <input
        v-model="tableState.search"
        @input="onFilterChange"
        type="text"
        placeholder="Szukaj..."
        class="search-input"
      />

      <select
        v-model="tableState.status"
        @change="onFilterChange"
        class="status-select"
      >
        <option value="all">Wszystkie</option>
        <option value="completed">Ukończone</option>
        <option value="pending">W trakcie</option>
      </select>
    </div>

    <div v-if="isLoading" class="status-msg">
      TanStack Query pobiera dane...
    </div>

    <!-- TABELA TANSTACK -->
    <table v-else>
      <thead>
        <tr
          v-for="headerGroup in table.getHeaderGroups()"
          :key="headerGroup.id"
        >
          <th
            v-for="header in headerGroup.headers"
            :key="header.id"
            @click="handleSort(header.column.id)"
            class="sortable"
          >
            <FlexRender
              :render="header.column.columnDef.header"
              :props="header.getContext()"
            />
            <span v-if="tableState.sortBy === header.column.id">
              {{ tableState.order === 'asc' ? ' 🔼' : ' 🔽' }}
            </span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in table.getRowModel().rows" :key="row.id">
          <td v-for="cell in row.getVisibleCells()" :key="cell.id">
            <FlexRender
              :render="cell.column.columnDef.cell"
              :props="cell.getContext()"
            />
          </td>
        </tr>
      </tbody>
    </table>

    <!-- PAGINACJA -->
    <div v-if="!isLoading" class="pagination-panel">
      <button
        :disabled="tableState.page <= 1"
        @click="changePage(tableState.page - 1)"
      >
        Poprzednia
      </button>
      <span>Strona {{ tableState.page }} z {{ totalPages }}</span>
      <button
        :disabled="tableState.page >= totalPages"
        @click="changePage(tableState.page + 1)"
      >
        Następna
      </button>

      <select v-model="tableState.limit" @change="onFilterChange">
        <option :value="5">Pokaż: 5</option>
        <option :value="10">Pokaż: 10</option>
      </select>
    </div>
  </div>
</template>

<style scoped>
  .table-container {
    font-family: sans-serif;
    margin: 20px;
    max-width: 900px;
  }
  .filters-panel {
    display: flex;
    gap: 15px;
    margin-bottom: 20px;
  }
  .search-input {
    flex-grow: 1;
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
  .status-select {
    padding: 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
  }
  table {
    width: 100%;
    border-collapse: collapse;
    margin-bottom: 20px;
  }
  th,
  td {
    border: 1px solid #ddd;
    padding: 12px;
    text-align: left;
  }
  th {
    background-color: #f4f4f4;
  }
  .sortable {
    cursor: pointer;
    user-select: none;
  }
  .sortable:hover {
    background-color: #e9e9e9;
  }
  .pagination-panel {
    display: flex;
    gap: 15px;
    align-items: center;
  }
  .pagination-panel button {
    padding: 8px 12px;
  }
  .status-msg {
    padding: 30px;
    text-align: center;
    background: #f5f5f5;
  }
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment