# How the Multi-Tab System with Keep-Alive Works in Celeris Web

> Discover how Celeris Web's multi-tab system with keep-alive efficiently manages tab state. Learn about reactive tab stores and Vue's `<keep-alive>` for seamless component caching and state preservation.

- Repository: [Kirk Lin/celeris-web](https://github.com/kirklin/celeris-web)
- Tags: internals
- Published: 2026-03-05

---

**Celeris Web implements a browser-like multi-tab interface by combining a reactive tab store in `useTabs` with Vue's `<keep-alive>` component to cache route instances and preserve component state across tab switches.**

The `kirklin/celeris-web` repository provides a Vue 3 admin dashboard framework featuring a sophisticated multi-tab navigation system. By leveraging Vue's built-in `<keep-alive>` mechanism alongside a custom composable for tab state management, the application allows users to switch between open pages without losing form data, scroll position, or component state. This article examines the complete implementation of the keep-alive multi-tab architecture, from route tracking to UI rendering.

## Tab State Management with useTabs

The foundation of the multi-tab system lives in [`apps/admin/src/composables/useTabs.ts`](https://github.com/kirklin/celeris-web/blob/main/apps/admin/src/composables/useTabs.ts). This composable provides a reactive store that tracks open routes and separates them into two distinct categories: recently accessed tabs and user-pinned tabs.

The core API includes several key functions:

- **`addTab(route)`**: Automatically invoked on every route change via `listenToRouteChange`. Inserts the route into a *limit list* that maintains a maximum number of recent tabs.
- **`pinnedTab(tab)`**: Moves a tab from the recent list to the *pinned list*, ensuring it persists across sessions.
- **`closeTab(tab)` and `closePinnedTab(tab)`**: Remove tabs from their respective lists and trigger cache cleanup.
- **`getLimitTabsList()`** and **`getPinnedTabsList()`**: Return reactive arrays consumed by the UI components.
- **`getCurrentTab()`**: Identifies which tab matches the current router location for active state highlighting.

To ensure persistence, the composable synchronizes the pinned tabs list to **localStorage** under the key `celeris_tabs`. This allows critical navigation items to survive full page reloads while transient tabs remain in memory only.

## Rendering the Tab Bar in LayoutTabs.vue

The visual interface resides in [`apps/admin/src/layouts/tabs/index.vue`](https://github.com/kirklin/celeris-web/blob/main/apps/admin/src/layouts/tabs/index.vue). This component renders both the recent tabs and pinned tabs using Naive UI's `NTag` component wrapped in Vue `TransitionGroup` elements for smooth fade animations.

The template structure separates concerns into two distinct lists:

```vue
<template>
  <div class="flex layout-tags items-end">
    <!-- Recent tabs (limit list) -->
    <TransitionGroup name="fade" tag="div" class="latest-list">
      <NTag v-for="tab in getLimitTabsList()" :key="tab.fullPath"
            round :bordered="false" closable @close="closeTab(tab)">
        <span class="router-name"
              :class="{ 'current-tab': getCurrentTab()?.fullPath === tab.fullPath }"
              @click="go(tab.fullPath)">
          {{ localize(tab.title) }}
        </span>
        <template #icon>
          <div @click="pinnedTab(tab)">
            <CAIcon :size="14" icon="tabler:pinned" />
          </div>
        </template>
      </NTag>
    </TransitionGroup>

    <!-- Pinned tabs -->
    <TransitionGroup name="fade" tag="div" class="pinned-list">
      <NTag v-for="tab in getPinnedTabsList()" :key="tab.name"
            round :bordered="false" closable @close="closePinnedTab(tab)">
        <div class="router-name"
             :class="{ 'current-tab': getCurrentTab()?.fullPath === tab.fullPath }"
             @click="go(tab.fullPath)">
          {{ localize(tab.title) }}
        </div>
      </NTag>
    </TransitionGroup>
  </div>
</template>

```

The component initializes the tab tracking system by calling `listenToRouteChange(route => addTab(route))` in its setup script. When users click a tab, the `go(fullPath)` function executes `router.push({ path: fullPath })`, triggering the navigation that activates the cached component instance.

## Component Caching via Keep-Alive

The state preservation mechanism centers on Vue's `<keep-alive>` wrapper located in the root layout at [`apps/admin/src/layouts/default.vue`](https://github.com/kirklin/celeris-web/blob/main/apps/admin/src/layouts/default.vue). The layout wraps the main `<router-view>` in a `<keep-alive>` element bound to a computed `cachedNames` array.

```vue
<template>
  <LayoutHeader />
  <LayoutTabs />
  <keep-alive :include="cachedNames">
    <router-view v-slot="{ Component }">
      <component :is="Component" />
    </router-view>
  </keep-alive>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();

const cachedNames = computed(() =>
  route.matched
    .filter(r => r.meta?.keepAlive)
    .map(r => r.components?.default?.name)
    .filter(Boolean)
);
</script>

```

The `cachedNames` computed property dynamically builds a list of component names that should remain in memory. It inspects the current route's matched records, filtering for those with `meta.keepAlive` set to `true`, then extracts the component names. When users switch between tabs, Vue retrieves the existing component instance from its internal cache rather than mounting a new one, preserving all reactive state including form inputs and scroll positions.

## Route Configuration for Persistent State

Enabling keep-alive behavior requires explicit configuration in the route definitions. Developers enable caching by adding `meta: { keepAlive: true }` to specific routes.

Consider this example from [`apps/admin/src/router/routes/dashboard.ts`](https://github.com/kirklin/celeris-web/blob/main/apps/admin/src/router/routes/dashboard.ts):

```typescript
export default {
  path: "/dashboard/overview",
  name: "DashboardOverview",
  component: () => import("~/pages/dashboard/components/DataOverview.vue"),
  meta: { 
    title: "Data Overview", 
    keepAlive: true  // Enables caching for this component
  }
};

```

When a user navigates to this route, the following sequence occurs:

1. The router resolves the route and `addTab` records it in the limit list.
2. [`LayoutTabs.vue`](https://github.com/kirklin/celeris-web/blob/main/LayoutTabs.vue) renders the new tab in the UI.
3. The `cachedNames` computed property includes `"DashboardOverview"` in the keep-alive include list.
4. Vue caches the component instance upon initial mount.
5. Subsequent visits retrieve the cached instance instead of re-creating it.

## Pinning, Closing, and Cache Lifecycle

The system distinguishes between transient and persistent tabs through the pinning mechanism. When users click the pin icon in [`LayoutTabs.vue`](https://github.com/kirklin/celeris-web/blob/main/LayoutTabs.vue), the `pinnedTab` function moves that route object from the limit list to the pinned list. This action immediately persists to localStorage, ensuring the tab reappears after browser refreshes.

Closing a tab—whether pinned or recent—invokes `closeTab` or `closePinnedTab`, which removes the entry from the reactive store. Because `<keep-alive>` binds to `cachedNames`, which derives from the current route's metadata rather than the tab store directly, the cache implicitly releases components when their corresponding routes are no longer matched. However, as long as a tab remains open (either in the recent or pinned list), users can return to it and find their state intact.

## Summary

- **[`useTabs.ts`](https://github.com/kirklin/celeris-web/blob/main/useTabs.ts)** provides the reactive state management for the multi-tab system, including automatic route tracking and localStorage persistence for pinned tabs.
- **[`LayoutTabs.vue`](https://github.com/kirklin/celeris-web/blob/main/LayoutTabs.vue)** renders the interactive tab bar using `NTag` components with `TransitionGroup` animations for smooth UX.
- **`<keep-alive>`** in the root layout caches component instances based on the `cachedNames` computed property, which filters routes by their `meta.keepAlive` flag.
- **Route configuration** determines which components remain cached by setting `meta: { keepAlive: true }` in the route definition.
- **Pinning functionality** moves tabs between a transient limit list and a persisted pinned list, with state surviving full page reloads via localStorage.

## Frequently Asked Questions

### How does the keep-alive multi-tab system preserve component state in Celeris Web?

The system wraps the `<router-view>` in a `<keep-alive>` element with an `:include` binding to `cachedNames`. This computed property collects the names of components whose routes have `meta.keepAlive` set to `true`. When users switch tabs, Vue retrieves the existing component instance from its internal cache rather than creating a new one, preserving form data, scroll position, and other reactive state.

### What is the difference between pinned tabs and recent tabs in Celeris Web?

**Pinned tabs** are managed via `pinnedTab()` and stored in `localStorage` under the key `celeris_tabs`, ensuring they persist across browser sessions. **Recent tabs** (the limit list) exist only in memory and automatically expire when they exceed the maximum allowed number or when explicitly closed via `closeTab()`. Both types render in [`LayoutTabs.vue`](https://github.com/kirklin/celeris-web/blob/main/LayoutTabs.vue) but in separate `TransitionGroup` containers.

### Where does the tab state management logic reside in the codebase?

The primary logic lives in [`apps/admin/src/composables/useTabs.ts`](https://github.com/kirklin/celeris-web/blob/main/apps/admin/src/composables/useTabs.ts), which exports functions like `addTab`, `closeTab`, `pinnedTab`, and reactive getters for both tab lists. The UI layer resides in [`apps/admin/src/layouts/tabs/index.vue`](https://github.com/kirklin/celeris-web/blob/main/apps/admin/src/layouts/tabs/index.vue), while the caching configuration appears in the root layout at [`apps/admin/src/layouts/default.vue`](https://github.com/kirklin/celeris-web/blob/main/apps/admin/src/layouts/default.vue).

### How do I enable keep-alive for a specific page in Celeris Web?

Add `keepAlive: true` to the route's metadata in your route definition file. For example:

```typescript
{
  path: "/your-path",
  name: "YourComponent",
  component: () => import("~/pages/YourComponent.vue"),
  meta: { keepAlive: true }
}

```

Ensure your component has a defined `name` property, as `<keep-alive>` matches against component names to determine which instances to cache.