Svelte component basics: real-world lessons and examples
Svelte components are where the framework's philosophy truly shines. Unlike frameworks that split logic, markup, and styles into separate files or layers, Svelte lets you keep everything in one .svelte file—making components self-contained and easier to reason about. Over three days of building features in production systems, we've learned that this unified approach catches bugs earlier and reduces the friction between thinking about a feature and implementing it.
Understanding the three sections of a Svelte component
Every Svelte component follows a simple structure. The <script> block contains your logic, the markup sits in the middle, and <style> wraps your scoped CSS. This separation is syntactic—Svelte compiles all three parts into a single, efficient JavaScript module. When you're building a real feature, this cohesion means less context-switching and fewer bugs from mismatched variable names or forgotten event handlers.
Let's ground this in a concrete example. Suppose we're building a user profile card for a SaaS dashboard. The card needs to load user data, handle a follow button, and display some basic styling.
<script lang="ts">
import { onMount } from 'svelte';
export let userId: string;
let user: { name: string; email: string; isFollowing: boolean } | null = null;
let loading = true;
let error: string | null = null;
onMount(async () => {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
user = await response.json();
} catch (err) {
error = (err as Error).message;
} finally {
loading = false;
}
});
async function toggleFollow() {
if (!user) return;
user.isFollowing = !user.isFollowing;
// Optimistic update, then sync to backend
await fetch(`/api/users/${userId}/follow`, {
method: 'POST',
body: JSON.stringify({ follow: user.isFollowing })
});
}
</script>
<div class="card">
{#if loading}
<p>Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else if user}
<h3>{user.name}</h3>
<p>{user.email}</p>
<button
on:click={toggleFollow}
class:following={user.isFollowing}
>
{user.isFollowing ? 'Following' : 'Follow'}
</button>
{/if}
</div>
<style>
.card {
border: 1px solid #ddd;
padding: 1.5rem;
border-radius: 8px;
max-width: 300px;
}
h3 {
margin: 0 0 0.5rem 0;
}
p {
color: #666;
margin: 0 0 1rem 0;
}
button {
background: #007bff;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
button.following {
background: #6c757d;
}
.error {
color: #dc3545;
}
</style>In this single file, we've defined the component's interface (the userId prop), its reactive state, async behavior, event handling, conditional rendering, and styling. Everything a developer needs to understand this card lives right here.
Script blocks and logic with lang="ts"
The <script lang="ts"> block is where you define component logic. Setting lang="ts" enables TypeScript, which we strongly recommend for production code. Type safety catches mismatches at compile time rather than in the user's browser.
In the script block, you export props, declare reactive variables, import utilities and lifecycle functions, and define event handlers. One critical pattern we've adopted is separating component props from internal state. Props are exported and flow downward; state is internal and drives reactivity.
<script lang="ts">
export let itemId: string;
export let onSelect: (id: string) => void = () => {};
let quantity = 1;
let inStock = true;
function handleAddToCart() {
// quantity is a reactive variable; any change triggers updates
quantity += 1;
onSelect(itemId);
}
</script>Notice that reactive variables (like quantity) don't need special syntax—just declare them with let. Svelte's compiler automatically tracks assignments and schedules updates. This is less verbose than React's useState, though the mental model is similar.
In e-commerce systems we've built, this simplicity has reduced boilerplate significantly. A form component that manages input state doesn't require separate state-setter functions; you just reassign the variable.
HTML markup with reactive expressions
Between the script and style blocks, you write HTML with embedded JavaScript expressions using curly braces. This is Svelte's templating syntax, and it's much closer to standard HTML than JSX.
<script lang="ts">
let firstName = 'Alice';
let lastName = 'Chen';
let age = 28;
let isActive = true;
function greet() {
alert(`Hello, ${firstName}!`);
}
</script>
<h2>{firstName} {lastName}</h2>
<p>Age: {age}</p>
<p>Status: {isActive ? 'Active' : 'Inactive'}</p>
<button on:click={greet}>Say Hello</button>Reactive expressions are evaluated whenever the variables they depend on change. The template is re-rendered efficiently; Svelte figures out exactly which parts of the DOM need updating. Unlike React, there's no dependency array to forget.
For conditional rendering, use {#if ...}, {:else if ...}, and {:else} blocks. For lists, use {#each items as item (key)}. These are block-level constructs, not functions, so they integrate seamlessly with HTML.
<script lang="ts">
let orders: Array<{ id: string; total: number }> = [];
onMount(async () => {
orders = await fetchOrders();
});
</script>
{#if orders.length === 0}
<p>No orders yet.</p>
{:else}
<ul>
{#each orders as order (order.id)}
<li>Order {order.id}: ${order.total}</li>
{/each}
</ul>
{/if}The key in parentheses after the iterator is critical. It helps Svelte maintain object identity across re-renders, especially important when you're mutating nested state or relying on element focus.
Scoped styles and avoiding CSS conflicts
The <style> block in a Svelte component is scoped by default. This means CSS rules only apply to that component's markup, not globally. Svelte achieves this by adding a unique class name to each component at compile time.
For teams working on large dashboards or marketplaces, this is a game-changer. You don't need to worry about naming conventions or selector specificity wars. A developer working on a payment form can use simple class names like .button or .error without colliding with another team's styles.
<script lang="ts">
let disabled = false;
</script>
<button {disabled}>Submit</button>
<style>
/* This button style only applies to this component */
button {
padding: 0.75rem 1.5rem;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>If you need global styles, place them in a top-level layout or use the :global() modifier to break scoping for specific rules.
<style>
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto;
}
/* Scoped to this component */
.panel {
border: 1px solid #eee;
}
</style>In production CRM interfaces we've maintained, scoped styles have been essential. Forms, modals, and lists from different developers live side by side without CSS leakage. This reduces debugging time and makes refactoring safer.
One component per .svelte file
The convention is one component per file. A file named UserCard.svelte exports a single, default component. This keeps the codebase navigable and makes it easy to find where a component is defined.
In practice, you'll import and nest components together. If you're building a user list view, you might have a parent UserList.svelte that renders multiple UserCard.svelte instances:
<script lang="ts">
import UserCard from './UserCard.svelte';
let users: Array<{ id: string; name: string }> = [];
onMount(async () => {
users = await fetchUsers();
});
</script>
<div class="user-list">
{#each users as user (user.id)}
<UserCard userId={user.id} on:select />
{/each}
</div>Notice the on:select directive. This forwards events from child components up to the parent. Svelte's event system is straightforward: dispatch events from children, listen in parents, and pass data through the event detail.
Common mistakes
- Forgetting to export props. If you want a component to accept a value from its parent, use
export let propName. Without theexportkeyword, it's just an internal variable. - Mutating objects without reassignment. Svelte tracks top-level assignments. If you have an array
itemsand push to it, the component won't always re-render. Instead, useitems = [...items, newItem]oritems.push(newItem); items = items. - Missing keys in each loops. Always provide a key when iterating over dynamic lists. Without one, Svelte may reuse DOM nodes incorrectly, causing state leaks or animation glitches.
- Over-scoping styles. Remember that styles are scoped. If you need a style to apply globally (like body font), use
:global()or put it in a layout component. - Importing components without default export. Svelte components are default exports. Make sure you're using
import ComponentName from './path', not destructuring.
Key takeaways
- Svelte components unify script, markup, and styles in a single file, reducing context-switching and making components self-contained.
- Use
<script lang="ts">for type-safe logic; declare reactive variables withletand export props withexport let. - Reactive expressions in markup use
{}syntax and update automatically when dependencies change, with no dependency arrays to maintain. - Scoped styles by default prevent CSS conflicts across components, making large codebases safer and easier to refactor.
- Follow the one-component-per-file convention for navigability and clarity.
- Pay attention to reactivity rules, especially array mutations and object updates, to avoid subtle bugs.






Comments
Loading comments…