The Svelte Shift: From React/Redux to SvelteKit5
by Igor Tosic, Software Engineer
The Svelte Shift: From React/Redux to SvelteKit5
When we are talking about reactivity on frameworks in the frontend world, I was, or I am still, a big fan of SolidJS reactivity. It is simpler and better than React. But now I am testing SvelteKit5, which I hadn't done until recently. I've read about Svelte but never tried it... However, Svelte5 attracted me because of runes and the possibility of better state management than Redux, along with a more efficient framework. Svelte is a compiler that is much faster than Next.js and other frameworks. So I decided to migrate my NextJS project to SvelteKit5 to compare pros and cons and how fast we can migrate our frontend.
SolidJS is a good framework, but it is still not recognized in the market, and the community is not big. However, Svelte is ready to take the next step in the frontend world, and big companies and systems can start thinking about implementing it as part of their tech stack!
A Fundamental Shift in Architecture
One of the first things I noticed when migrating to SvelteKit - CRM demo project was the fundamentally different approach to layouts and routing. In NextJS/React, we're used to explicitly importing layout components and wrapping our pages manually. With SvelteKit, I discovered that layouts automatically wrap pages in their directories through a file-based system.
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import '../app.css';
import ProtectedLayout from "$lib/components/layouts/protected-layout.svelte";
import type { LayoutData } from './$types';
let { data, children } = $props<{data: LayoutData, children: any}>();
</script>
{#if data.user}
<ProtectedLayout user={data.user}>
{@render children()}
</ProtectedLayout>
{:else}
{@render children()}
{/if}
This simple layout file automatically wraps every page in the application, conditionally applying a protected layout for authenticated users. No manual imports, no explicit wrapping - just a clean, declarative approach to page structure.
The Data Flow Revolution: Built-in State Management
Perhaps the most mind-blowing aspect of SvelteKit for me, coming from React/Redux, was discovering how data flows through the application. In Redux, we spend hours setting up stores, actions, reducers, and connectors. With SvelteKit, I found myself with what felt like a hidden "global state provider" built right into the framework.
The SvelteKit Data Loading Chain
When you create a +layout.server.ts
file in SvelteKit, it automatically acts as a data provider for:
- That layout file (
+layout.svelte
) - Any page within that directory (
+page.svelte
) - Any child layouts/pages in subdirectories
This means data can cascade through your entire application without any explicit prop drilling or context providers. The framework just handles it.
// /routes/+layout.server.ts
export const load = () => {
return {
user: { id: 1, name: "Alice" },
settings: { theme: "dark" }
};
};
// /routes/dashboard/+layout.server.ts
export const load = ({ parent }) => {
// Get parent data first! This connects to the upstream "provider"
const parentData = await parent();
return {
// Add new dashboard-specific data
tasks: ["Task 1", "Task 2"],
// You can also transform parent data if needed
userWithRole: {
...parentData.user,
role: "admin"
}
};
};
Then in ANY component within the hierarchy, you can just access the data:
<script>
// In any +page.svelte or +layout.svelte
let { data } = $props<{data: PageData}>();
// data now contains:
// {
// user: { id: 1, name: "Alice" },
// settings: { theme: "dark" },
// tasks: ["Task 1", "Task 2"],
// userWithRole: { id: 1, name: "Alice", role: "admin" }
// }
</script>
<h1>Welcome, {data.user.name}!</h1>
<p>You have {data.tasks.length} tasks</p>
The Redux Mental Model vs. The Svelte Approach
Coming from Redux, I initially approached Svelte stores with the same mental model. Here's how I first implemented my client store, following a very Redux-like pattern:
// Redux-like approach in Svelte
import { writable, derived, type Writable } from 'svelte/store';
import type { Client, LeadStatus, PaginationState } from '$lib/types';
// Define the state structure
interface ClientsState {
clients: Client[];
pagination: PaginationState;
filters: {
search: string;
lead: LeadStatus | null;
};
loading: boolean;
error: string | null;
}
// Initial state
const initialState: ClientsState = {
clients: [],
pagination: {
currentPage: 1,
totalPages: 1,
totalItems: 0,
itemsPerPage: 10,
},
filters: {
search: '',
lead: null,
},
loading: false,
error: null,
};
// Create the writable store
function createClientsStore() {
const { subscribe, set, update }: Writable<ClientsState> = writable(initialState);
return {
subscribe,
// Set entire state - useful when hydrating from SSR
setData: (data: {
clients: Client[];
pagination: PaginationState;
filters?: { search?: string; lead?: LeadStatus | null }
}) => {
update(state => ({
...state,
clients: data.clients,
pagination: data.pagination,
filters: {
search: data.filters?.search || '',
lead: data.filters?.lead || null,
},
loading: false,
error: null
}));
},
// Set loading state
setLoading: (isLoading: boolean) => {
update(state => ({ ...state, loading: isLoading }));
},
// Set error state
setError: (error: string | null) => {
update(state => ({ ...state, error, loading: false }));
},
// Update filters
updateFilters: (filters: { search?: string; lead?: LeadStatus | null }) => {
update(state => ({
...state,
filters: { ...state.filters, ...filters }
}));
},
// Reset to initial state
reset: () => set(initialState)
};
}
// Create the store instance
export const clientsStore = createClientsStore();
// Derived stores for convenience
export const filteredClients = derived(clientsStore, $store => {
let filtered = [...$store.clients];
// Apply lead filter if set
if ($store.filters.lead) {
filtered = filtered.filter(client => client.lead === $store.filters.lead);
}
// Apply search filter if set
if ($store.filters.search) {
const searchLower = $store.filters.search.toLowerCase();
filtered = filtered.filter(client =>
client.company_name?.toLowerCase().includes(searchLower) ||
client.first_name.toLowerCase().includes(searchLower) ||
client.last_name.toLowerCase().includes(searchLower) ||
client.email.toLowerCase().includes(searchLower)
);
}
return filtered;
});
// Loading state
export const isLoading = derived(clientsStore, $store => $store.loading);
// Error state
export const error = derived(clientsStore, $store => $store.error);
This approach works, but it's heavy with Redux concepts, including:
- A single large state object
- Immutable update patterns
- Action creator functions
- A lot of boilerplate
As I became more comfortable with Svelte, I realized there's a much simpler, more idiomatic Svelte approach:
// Svelte-native approach
import { writable, derived } from 'svelte/store';
import type { Client, LeadStatus, PaginationState } from '$lib/types';
// Separate stores for different concerns
export const clients = writable<Client[]>([]);
export const pagination = writable<PaginationState>({
currentPage: 1,
totalPages: 1,
totalItems: 0,
itemsPerPage: 10,
});
export const filters = writable<{
search: string;
lead: LeadStatus | null;
}>({
search: "",
lead: null,
});
// Loading and error states
export const loading = writable(false);
export const error = writable<string | null>(null);
// Derived store for filtered clients
export const filteredClients = derived([clients, filters], ([$clients, $filters]) => {
// If no filters are applied, return all clients
if (!$filters.search && !$filters.lead) {
return $clients;
}
// Apply filters
return $clients.filter((client) => {
// Filter by lead status
if ($filters.lead && client.lead !== $filters.lead) {
return false;
}
// Filter by search term
if ($filters.search) {
const term = $filters.search.toLowerCase();
return (
client.company_name?.toLowerCase().includes(term) ||
client.first_name.toLowerCase().includes(term) ||
client.last_name.toLowerCase().includes(term) ||
client.email.toLowerCase().includes(term)
);
}
return true;
});
});
// Simple action to set clients data
export function setClientsData(data: {
clients: Client[];
pagination: PaginationState;
filters?: { search?: string; lead?: LeadStatus | null };
}) {
clients.set(data.clients);
pagination.set(data.pagination);
if (data.filters) {
filters.update((currentFilters) => ({
...currentFilters,
search: data.filters?.search || "",
lead: data.filters?.lead || null,
}));
}
}
// Action to update filters
export function updateFilters(newFilters: { search?: string; lead?: LeadStatus | null }) {
filters.update((f) => ({ ...f, ...newFilters }));
}
// Action to reset filters
export function resetFilters() {
filters.set({ search: "", lead: null });
}
// Action to set loading state
export function setLoading(isLoading: boolean) {
loading.set(isLoading);
}
// Action to set error state
export function setError(errorMessage: string | null) {
error.set(errorMessage);
}
This approach is much cleaner, with:
- Separate, focused stores for specific pieces of state
- Simpler update functions
- Automatic reactivity through derived stores
- Minimal boilerplate
Integrating SvelteKit Data Flow with Stores
One of the most elegant patterns I discovered was integrating SvelteKit's server data loading with client-side stores. In my CRM application, I implemented a hybrid approach:
- Server-side data loading in
+page.server.ts
:
// src/routes/clients/+page.server.ts
export const load: PageServerLoad = async ({ cookies, url }) => {
// Get query parameters
const page = Number(url.searchParams.get("page") || "1");
const search = url.searchParams.get("search") || "";
const lead = url.searchParams.get("lead") as LeadStatus | null;
// Fetch data with filters
const clientsResponse = await listClients(cookies, {
page,
itemsPerPage: 10,
search,
lead: lead || undefined,
});
return {
clients: clientsResponse.data,
pagination: clientsResponse.pagination,
filters: {
search,
lead,
},
};
};
- Client-side store initialization in
+page.ts
:
// src/routes/clients/+page.ts
import type { PageLoad } from "./$types";
import { setClientsData } from "$lib/store/clients";
import { browser } from "$app/environment";
export const load: PageLoad = async ({ data, depends }) => {
// Register a dependency to ensure the data is re-fetched when navigating
depends("app:clients");
// If we're in the browser, update the store with the data from the server
if (browser) {
setClientsData({
clients: data.clients,
pagination: data.pagination,
filters: data.filters,
});
}
// Return the data as-is for the page component
return data;
};
- Data access in components via props and/or stores:
<!-- src/lib/components/clients/client-list.svelte -->
<script lang="ts">
import type { PaginationState, LeadStatus } from "$lib/types";
import SearchBar from "./search-bar.svelte";
import Filters from "./filters.svelte";
import ClientTable from "./client-table.svelte";
import RefreshButton from "./refresh-button.svelte";
import { Button } from "$lib/components/ui/button/index.js";
import Plus from "@lucide/svelte/icons/plus";
// Import stores
import {
filteredClients,
pagination as paginationStore,
filters as filtersStore
} from "$lib/store/clients";
// We still accept props for the initial SSR data
let { clients, pagination, filters = {} } = $props<{
clients: any[];
pagination: PaginationState;
filters: { search?: string; lead?: LeadStatus };
}>();
// Use store values directly with $ prefix and $derived for reactivity
const clientsList = $derived($filteredClients);
const paginationData = $derived($paginationStore);
const currentFilters = $derived($filtersStore);
// Compute number of clients found
const clientsFound = $derived(
`${paginationData.totalItems} ${paginationData.totalItems === 1 ? 'client' : 'clients'} found`
);
</script>
<!-- Component markup would go here, using clientsList, paginationData, etc. -->
This approach gives me the best of both worlds:
- Server-rendered initial data for SEO and performance
- Client-side reactivity for instant UI updates
- A consistent data pattern throughout my application
Svelte5 Runes: A New Level of Reactivity
Svelte5 introduces runes - a new syntax for reactivity that makes the code even more elegant.
Runes like $state()
, $derived
, and the $
prefix for store subscriptions create a unified reactive programming model:
<script lang="ts">
import {
updateFilters,
filters,
loading
} from "$lib/store/clients";
import { Button } from "$lib/components/ui/button/index.js";
import type { LeadStatus } from "$lib/types";
const { activeFilter = "all" } = $props<{ activeFilter?: LeadStatus | "all" }>();
// Access store directly with $derived
const isLoading = $derived($loading);
const storeFilters = $derived($filters);
function handleFilterChange(filter: LeadStatus | "all") {
// Update store directly
if (filter === "all") {
updateFilters({ lead: null });
} else {
updateFilters({ lead: filter });
}
// Update URL without page reload
const url = new URL(window.location.href);
if (filter === "all") {
url.searchParams.delete("lead");
} else {
url.searchParams.set("lead", filter);
}
url.searchParams.delete("page"); // Reset to first page when filtering
history.pushState({}, "", url.toString());
}
</script>
The $derived
rune creates variables that automatically update when their dependencies change, eliminating the need for useEffect-style hooks or complicated state management.
No More "Provider Hell"
One of the most striking differences I found between React/Redux and Svelte is the simplicity of data flow. In React, we often end up with deeply nested providers:
// React "Provider Hell"
<AuthProvider>
<ThemeProvider>
<DataProvider>
<LayoutProvider>
<App />
</LayoutProvider>
</DataProvider>
</ThemeProvider>
</AuthProvider>
With SvelteKit's built-in data loading and Svelte's stores, this entire hierarchy simply disappears. Data flows naturally from server to components, and stores are globally accessible without any provider wrappers.
Why Companies Should Consider Svelte for Their Tech Stack
After my experience migrating from Next.js-CRM-demo to CRM-SvelteKit5, I believe companies should seriously consider adopting Svelte for these reasons:
- Development Speed: Less boilerplate, fewer concepts, and a more intuitive API mean faster development cycles.
- Performance: Svelte's compile-time approach eliminates the need for a virtual DOM, resulting in smaller bundle sizes and faster runtime performance.
- Maintainability: The code is more readable and expresses intent more clearly than React's often complex component patterns.
- Learning Curve: Svelte builds on standard HTML, CSS, and JavaScript concepts, making it easier for new developers to learn.
- Built-in State Management: No need for Redux, MobX, Recoil, or other external libraries - Svelte's stores are simple, powerful, and built-in.
- Progressive Enhancement: SvelteKit's server-side rendering and progressive enhancement philosophy mean better user experiences across all devices.
The question shouldn't be "Why Svelte?" but rather "Why not?" Companies often default to React simply because of its popularity, but Svelte provides a more efficient, developer-friendly alternative that can lead to better products and happier development teams.
Conclusion
My journey from React/Redux to SvelteKit5 has been eye-opening. While React has dominated the frontend landscape for years, Svelte offers a fundamentally different approach that addresses many of React's pain points. Its compiler-centric design, intuitive reactivity system, and elegant state management make it a compelling alternative.
As for SvelteKit, it takes Svelte's strengths and builds a complete application framework that rivals Next.js in capabilities while maintaining Svelte's simplicity and developer experience. The integration of server and client rendering, the automatic data flow, and the file-based routing system all contribute to a development experience that feels natural and productive.
SolidJS is another excellent alternative, but Svelte's growing community and mature ecosystem make it a safer bet for companies looking to adopt a React alternative. I believe we're seeing the beginning of a shift in the frontend landscape, with Svelte positioned to capture a significant portion of the market as developers and companies realize its advantages.
For me, the migration has been well worth the effort, and I look forward to building more applications with Svelte and SvelteKit in the future.