How the Multi-Tab System with Keep-Alive Works in Celeris Web
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. 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 vialistenToRouteChange. 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)andclosePinnedTab(tab): Remove tabs from their respective lists and trigger cache cleanup.getLimitTabsList()andgetPinnedTabsList(): 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. 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:
<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. The layout wraps the main <router-view> in a <keep-alive> element bound to a computed cachedNames array.
<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:
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:
- The router resolves the route and
addTabrecords it in the limit list. LayoutTabs.vuerenders the new tab in the UI.- The
cachedNamescomputed property includes"DashboardOverview"in the keep-alive include list. - Vue caches the component instance upon initial mount.
- 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, 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.tsprovides the reactive state management for the multi-tab system, including automatic route tracking and localStorage persistence for pinned tabs.LayoutTabs.vuerenders the interactive tab bar usingNTagcomponents withTransitionGroupanimations for smooth UX.<keep-alive>in the root layout caches component instances based on thecachedNamescomputed property, which filters routes by theirmeta.keepAliveflag.- 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 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, 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, while the caching configuration appears in the root layout at 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:
{
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.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →