Introducing the new Render CLI and refreshed Render Dashboard.

Learn more
How-to
March 11, 2022

Design Patterns for Building Reusable Svelte Components

Eric Liu
Well-designed, reusable components allow software developers to benefit from layers of abstraction, building upon each other's work instead of reinventing the wheel. There are many articles on reusable component design in React; how do best practices differ in Svelte? At Render, we use React for our frontend but more recently started using Svelte for rapid prototyping because of its concise language syntax and built-in reactivity. For instance, this Svelte component was built with fewer than 200 lines of code:
Demo of svelte-bar-chart-race
But first, if you're new to Svelte, it's an open source compiler used to build web applications. It was voted the "most loved web framework" in the StackOverflow's 2021 Developer Survey and tops satisfaction and interest rankings for frontend frameworks in the 2021 State of JS survey. To explore component design patterns in Svelte, I've created a sample reusable library, svelte-bar-chart-race, that demonstrates how library authors can abstract away details while giving flexibility to users.
A bar chart race is a horizontal bar chart that animates bars over an interval (usually time). The bars animate as they are sorted based on their value from highest to lowest. The visualization is useful for observing trends over time.

The component library uses many Svelte features: context, stores, slots, lifecycle methods, and reactive statements/assignments. It's designed to be simple to get started with, while also offering capabilities to support more complex use cases. The code is published to npm and is open source. In this blog post, we'll review several design patterns for building highly reusable Svelte components using svelte-bar-chart-race as a case study. These patterns include:

Component Composition

svelte-bar-chart-race is comprised of three components:
  • BarChartRace.svelte: Parent component that accepts data, options and manages internal state
  • Chart.svelte: Child component that displays the chart with the ability to customize the chart display and animation
  • Slider.svelte: Child component that shows the range input to control the interval (current value)
import { BarChartRace, Slider, Chart } from "svelte-bar-chart-race";
You might be wondering: why are there three individual components instead of one? The first instinct when designing a component API is to expose all options using Svelte component properties. The approach of passing objects as properties, commonly called props, is known as objects as props. While technically valid, this approach is less expressive and intuitive than composing components and elements using slots. If svelte-bar-chart-race were a single component, it would require many props to afford a high degree of customization. As a result, this approach is rather inflexible if the end user wants control over how the components are positioned.
<!-- using objects as props to customize the component is less expressive -->
<BarChartRace
  {data}
  chartProps="{{
    showChart: true,
    position: 'top',
    options: {
      animate: {
        duration: 200,
      },
    },
  }}"
  sliderProps="{{
    showSlider: true,
    labelText: 'Year',
  }}"
/>
By breaking svelte-bar-chart-race into pieces, its composition gives the consumer greater flexibility when instantiating the bar chart race. The user has full control over which components to include and how to position them. BarChartRace is the parent component; child content is passed through the parent's default slot. For example, you can place the Slider component above or below Chart and pass any other elements through the BarChartRace slot. You can even choose to omit the Slider component and use your own method of controlling the bar chart race.
<!-- Basic usage -->
<BarChartRace {data}>
  <Slider />
  <Chart />
</BarChartRace>

<!-- Custom elements and component ordering -->
<BarChartRace {data}>
  <h1>Bar Chart Race</h1>
  <Chart />
  <Slider />
</BarChartRace>

<!-- Custom mode of interaction -->
<BarChartRace {data} let:play>
  <Chart />
  <button on:click="{play}">Play</button>
</BarChartRace>
This pattern is called component composition. Compared to having one giant component with numerous properties, component composition is a declarative approach that offers full control over instantiation and positioning.
Simple example composing a custom heading with the Chart and Slider components.

Managing State using Context

While slots are used to compose content, the Svelte Context API is used to share state between parent and child components. The parent uses setContext to define and pass state down to its children:
// BarChartRace.svelte
import { setContext } from "svelte";

setContext("BarChartRace", context);
The child component uses getContext to access state managed by BarChartRace. If a component is not a descendant of a parent component, getContext will return undefined. The child component can observe changes to stores by subscribing to the context value.
// Chart.svelte
const ctx = getContext("BarChartRace");

// subscribe to `value`
let unsubValue = ctx.value.subscribe((_value) => (value = _value));

// unsubscribe to changes when the component is destroyed
onDestroy(() => {
  unsubValue();
});
BarChartRace is the parent component because it uses setContext to pass down state. Slider and Chart must be child components of BarChartRace because they rely on its context.

As a result, the Context API masks the innner complexity and implementation of the component from the end user. Let's look at a more complex example now. Beyond just the Slider and Chart components, you can add buttons that animate the bar chart race and also customize values for the interval and animate properties.
<BarChartRace
  {data}
  interval={1_500}
  let:currentValue
  let:isPlaying
  let:setMax
  let:setMin
  let:play
  let:pause
>
  <button on:click={setMax}>Set max</button>
  <button on:click={setMin}>Set min</button>
  <button on:click={isPlaying ? pause : play}>
    {isPlaying ? "Pause" : "Play"}
  </button>
  <Slider labelText={currentValue} />
  <Chart
    let:datum
    --bar-height="10px"
    --margin-bottom="20px"
    animate={{ duration: 400, delay: 50 }}
  >
    <strong>{datum.title}:</strong>
    {datum.value}%
  </Chart>
</BarChartRace>
Kitchen sink example demonstrating the full API of the component.

Two-way Binding

Two-way binding exemplifies one of Svelte's key features: reactivity. In the previous example, we showed how the library can let users read its current state and trigger general actions. But what if users want to directly write back to the state? For that, we can use Svelte's two-way binding.
One-way vs. two-way binding: In one-way binding, you can only observe changes made to the bound value. Two-way binding allows you to observe changes and update the value programmatically.

Although it's possible to use svelte-bar-chart-race without two-way binding, it's an everyday use case to allow the end user to control the state programmatically. In the following example, currentValue will change when:
  • interacting with the Slider
  • clicking a button that updates the value programmatically
<script>
  import { BarChartRace, Slider, Chart } from "svelte-bar-chart-race";
  import { data } from "./data";

  let currentValue = null;
</script>

<BarChartRace {data} bind:currentValue>
<!-- slider updates the value internally -->
  <Slider />
  <Chart />
</BarChartRace>

<!-- buttons update the value programmatically -->
<button on:click="{() => (currentValue = 2020)}"> Set value to 2020 </button>
<button on:click="{() => (currentValue = null)}"> Set value to null </button>

Current value:
<strong>{currentValue}</strong>
Example demonstrating two-way binding.
Two-way binding on the currentValue prop is accomplished using reactive statements:
// BarChartRace.svelte

// update the internal value if `currentValue` is programmatically updated
$: value.set(currentValue);

// update `currentValue` if the internal value changes
$: currentValue = $value;

Slot Overrides

Slots are a Svelte language feature that enable the consumer to pass child content to specific areas within the component. Conversely, the slotted component can pass values to the consumer using the let: directive. The reason for using a default slot in BarChartRace.svelte is two-fold. First, the consumer can pass through Chart and Slider (or other components or elements). Second, the slot is passed variables and functions that the consumer can invoke. The BarChartRace component passes the current value through the slot in addition to helper functions that update its internal state.
<!-- BarChartRace.svelte -->
<slot
  {currentValue}
  {isPlaying}
  setMax="{() => value.set($range[$range.length - 1])}"
  setMin="{() => value.set($range[0])}"
  setValue="{context.setValue}"
  {play}
  pause="{clearTimer}"
/>
In the example below, you can access the currentValue and setValue props from the BarChartRace default slot.
<BarChartRace {data} let:currentValue let:setValue>
  <Slider labelText="{currentValue}" />
  <Chart />
  <button on:click="{() => setValue(2020)}">Set value to 2020</button>
</BarChartRace>
Slots also act as an escape hatch for end users to override values and content. For instance, Chart.svelte formats the display of the title, value, and unit by default.
<!-- Chart.svelte -->
<slot {datum}> {datum.title}: {datum.value}{options.unit ?? ''} </slot>
However, you can always override the default slot and format the display as you see fit. Overriding values through props is feasible, however the main benefit of a slot is to allow the user to compose custom elements and markup. Slots allow you to be more expressive compared to passing objects as props.
<!-- use the `strong` tag to bold the title -->
<Chart let:datum> <strong>{datum.title}</strong> {datum.value} </Chart>

Communicate with Types

Although optional, using TypeScript with Svelte can boost developer productivity and produce more robust code, which merits its inclusion as a pattern. You can incorporate TypeScript using a Svelte preprocessor through svelte-preprocess. In addition, extensions for Integrated Development Environments (IDEs) like VS Code or IntelliJ can help you be more efficient using Svelte. These extensions integrate the Svelte Language Server to provide language diagnostics and auto-completion.

Preventing Basic Type Errors

A basic benefit of static typing is to catch errors during development that would otherwise surface as runtime errors. For instance, it's easy to overlook that the type of event.currentTarget.value from an input element is a string, even if the input type is "number."
<input
  type="number"
  on:input="{(event) => {
    console.log(typeof event.currentTarget.value); // 'string'
  }}"
/>
Explicitly typing the value parameter in setValue as a number reminds you to convert the string to a number when passing it as an argument.
// Chart.svelte
ctx.setValue(Number(e.currentTarget.value));

Strongly Typing Svelte Features

Beyond basic type checking, another benefit of TypeScript is to strongly type Svelte features – like stores – to boost developer productivity. For instance, you can use the Writable interface for typing writable stores. Writable accepts a generic type parameter for the value passed to the store. In the following example, value is typed as Writable<number> which means that it expects a number type. As a result, writable(currentValue) will throw a type error because currentValue is nullable. This can easily prevent bugs in your component that would otherwise slip through the cracks.
import { writable } from "svelte/store";
import type { Writable } from "svelte/store";

export let currentValue: null | number = null;

// use the nullish coalescing operator to convert `null` to a number
const value: Writable<number> = writable(currentValue ?? -1);
You can also type Svelte Context as an interface with stores and functions as its properties.
interface BarChartRaceContext {
  value: Writable<number>;
  valuesByKey: Writable<BarChartRaceValuesByKey>;
  range: Writable<BarChartRaceRange>;
  chartOptions: Writable<Partial<BarChartRaceOptions>>;
  setValue: (value: number) => void;
}

const ctx: BarChartRaceContext = getContext("BarChartRace");

$: ctx.setValue(0);
svelte-bar-chart-race only uses stores and the Context API internally; they are not exposed as part of the public API to the end user.

Chart.svelte contains an example of typing a public API. The component uses the FlipParams type from svelte/animate to annotate the property. When using this component in an IDE with the Svelte Language Server enabled, the user can benefit from typeahead suggestions when customizing the animate prop.
// Chart.svelte
import type { FlipParams } from "svelte/animate";

export let animate: FlipParams = {};

Auto-generating Types using SvelteKit

It's strongly encouraged to publish Svelte components with corresponding TypeScript definitions. Type definitions serve to document the component library API, creating a safer, smoother developer experience for the end user. SvelteKit has become the de facto Svelte framework and is actively developed by the Svelte core team. In addition to building web apps, SvelteKit can package component libraries for publishing to npm. It auto-generates TypeScript definitions from Svelte components using svelte2tsx. In a SvelteKit setup, types can be generated by running the following command:
svelte-kit package
This is the generated TypeScript definition for Chart.svelte:
// Chart.svelte.d.ts
import { SvelteComponentTyped } from "svelte";
import type { FlipParams } from "svelte/animate";
declare const __propDef: {
    props: {
        /**
           * Customize the animation delay, duration, and easing.
           * @see https://svelte.dev/docs#run-time-svelte-animate-flip
           */ animate?: FlipParams;
    };
    events: {
        [evt: string]: CustomEvent<any>;
    };
    slots: {
        default: {
            datum: import("./shared").BarChartRaceDatum & import("./shared").BarChartRaceValue;
        };
    };
};
export declare type ChartProps = typeof __propDef.props;
export declare type ChartEvents = typeof __propDef.events;
export declare type ChartSlots = typeof __propDef.slots;
/** `Chart` must be descendant of `BarChartRace`. */
export default class Chart extends SvelteComponentTyped<ChartProps, ChartEvents, ChartSlots> {
}
export {};

As you can see, the definition file uses the SvelteComponentTyped interface, available since Svelte version 3.31. The interface can also be used for manually writing types.

Manually Authoring Types

Writing Svelte component definitions by hand using the SvelteComponentTyped interface is a viable alternative. The interface accepts three generic parameters. Use the first to type component props, the second for events, and the third for slots. Properties on the class instance denote component accessors. svelte-bar-chart-race uses the svelte-kit package command to generate its types.
import type { SvelteComponentTyped } from "svelte";

export default class SvelteComponent extends SvelteComponentTyped<
  { /** props */ },
  { /** events */ },
  { /** slots */ },
> {
    /** accessors */
}
This is what Chart.svelte.d.ts might look like if you were to manually type it:
// Chart.svelte.d.ts
import type { SvelteComponentTyped } from "svelte";
import type { FlipParams } from "svelte/animate";
import type { BarChartRaceDatum, BarChartRaceValue } from "./shared";

export default class Chart extends SvelteComponentTyped<
  {
    /**
     * Customize the animation delay, duration, and easing.
     * @see https://svelte.dev/docs#run-time-svelte-animate-flip
     */
    animate?: FlipParams;
  },
  {},
  {
    default: {
      datum: BarChartRaceDatum & BarChartRaceValue;
    };
  }
> {}

The Bigger Picture

In this post, we've gone over general design patterns to increase the reusability of your Svelte components using svelte-bar-chart-race as a practical example. The goal is to give the end user more control by composing components, overriding slots, and using two-way binding to observe and update state. Incorporating TypeScript during development can accelerate the development process, increase code quality, and improve the developer experience of the end user. However, there is no one-size-fits-all solution in practice. Before jumping straight to coding, carefully consider your use case by asking yourself these higher-level questions and using the answer to inform the design of your component.
  • Is your component mostly visual or interactive?
  • How much business logic would the user expect to control or override?
  • Would your user use custom elements when overriding slots or will objects as props suffice?
  • What props would the user expect to support two-way binding, if at all?
If you're interested in delving further into Svelte, try our SvelteKit quick start guide that deploys SvelteKit to Render as a Node.js web service.