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:
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.
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
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
Although it's possible to use
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)
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.
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.
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.
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 usessetContext
to define and pass state down to its children:
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.
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.
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
currentValue
prop is accomplished using reactive statements:
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 thelet:
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.
In the example below, you can access the currentValue
and setValue
props from the BarChartRace
default slot.
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.
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.
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 ofevent.currentTarget.value
from an input
element is a string, even if the input
type is "number."
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.
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 theWritable
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.
You can also type Svelte Context as an interface with stores and functions as its properties.
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.
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: This is the generated TypeScript definition forChart.svelte
:
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 theSvelteComponentTyped
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.
This is what Chart.svelte.d.ts
might look like if you were to manually type it:
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?