Sharing State Without Props And Events In Svelte
Published Nov 22, 2024
Table of Contents
- The Prop Drilling Problem
- Creating Your Own Context API
- The Svelte Context API
- Global State Versus Context
- Passing Reactive State To Context
- Use A Unique Key For Context
- Encapsulating And Typing Context
- Practical Example Of Using The Context API
The Prop Drilling Problem
You might have heard of the term โprop drillingโ, which describes sending the same data from a parent component through every child component even if only one component cares about it:
<A>
<B {prop}>
<C {prop}>
<D {prop} /> <!-- only this component cares about `prop` -->
</C>
</B>
</A>
This is tedious, but Svelte provides the Context API to share state between components without using props and events which looks like this:
<A> <!-- set context in parent -->
<B>
<C>
<D /> <!-- get context in child -->
</C>
</B>
</A>
In this post, weโre going to create a naive implementation of the Context API from scratch to understand how it works at a basic level, and then weโre going to learn how the Svelte Context API works.
Creating Your Own Context API
Letโs pretend the Context API doesnโt exist.
In this example, we want to pass banana
from component <A>
to component <D>
, but unfortunately we also have to pass it through every nested child component <B>
and <C>
:
<!-- src/routes/+page.svelte -->
<script lang="ts">
import A from '/A.svelte'
</script>
<A />
<!-- src/routes/A.svelte -->
<script lang="ts">
import B from './B.svelte'
const banana = $state({ value: '๐' })
</script>
<B {banana} />
<!-- src/routes/B.svelte -->
<script lang="ts">
import C from './C.svelte'
let { banana } = $props()
</script>
<C {banana} />
<!-- src/routes/C.svelte -->
<script lang="ts">
import D from './D.svelte'
let { banana } = $props()
</script>
<D {banana} />
<!-- src/routes/D.svelte -->
<script lang="ts">
let { banana } = $props()
</script>
<pre>{JSON.stringify(banana, null, 2)}</pre>
Letโs create the same functions the Svelte Context API provides, which are setContext
, getContext
, hasContext
, and getAllContexts
:
// using a Map object for storing key-value pairs
const context = new Map()
// sets the value inside the Map
export function setContext(key: any, value: any) {
context.set(key, value)
}
// gets the value from the Map
export function getContext(key: any) {
return context.get(key)
}
// checks if the Map has the value
export function hasContext(key: any) {
return context.has(key)
}
// returns the Map object
export function getAllContexts() {
return context
}
Why use the Svelte Context API when we have context at home, right?
This is only a naive implementation of how Svelte implements the Context API, but as you can see, the Context API is just a JavaScript Map object that holds key-value pairs.
The major difference is that Svelte scopes the context to the component tree, so itโs only available to the parent and its children.
Letโs set the context in the parent component <A>
and get the context in the deeply nested child component <D>
removing the need for passing banana
to child components:
<!-- src/routes/A.svelte -->
<script lang="ts">
import { setContext } from './context-at-home'
import B from './B.svelte'
const banana = $state({ value: '๐' })
setContext('banana', banana)
</script>
<B />
<!-- src/routes/B.svelte -->
<script lang="ts">
import C from './C.svelte'
</script>
<C />
<!-- src/routes/C.svelte -->
<script lang="ts">
import D from './D.svelte'
</script>
<D />
<!-- src/routes/D.svelte -->
<script lang="ts">
import { getContext, getAllContexts } from './context-at-home'
console.log(getContext('banana')) // Proxy(Object) {value: '๐'}
</script>
<pre>{JSON.stringify([...getAllContexts()], null, 2)}</pre>
This is how the Context API works at a basic level, and you can see itโs not magic.
The Svelte Context API
To use Svelteโs Context API, the only thing we have to change is the import:
<!-- src/routes/A.svelte -->
<script lang="ts">
import { setContext } from 'svelte'
import B from './B.svelte'
const banana = $state({ value: '๐' })
setContext('banana', banana)
</script>
<B />
<!-- src/routes/D.svelte -->
<script lang="ts">
import { getContext, getAllContexts } from 'svelte'
console.log(getContext('banana')) // Proxy(Object) {value: '๐'}
</script>
<pre>{JSON.stringify([...getAllContexts()], null, 2)}</pre>
Global State Versus Context
To understand the drawback of using global state compared to using context, we need to understand how Svelteโs Context API works.
In this example, we set the context inside component <A>
and ask for it in component <D>
where Svelte is going to walk up the component tree until it finds the context โ if we set the context inside component <B>
it would only be available to that component and its children.
The context is scoped to the component tree, so itโs only available to the parent and its children, where global state makes more sense for state used by the entire app:
// global state
export const config = $state({ theme: 'dark' })
Using global state is unsafe on the server if youโre using SvelteKit because it could be shared between sessions and users, but you can pass state with context safely to those components.
Passing Reactive State To Context
In our example, the banana
is already reactive because Svelte uses a Proxy object under the hood for objects and arrays that turns properties into signals:
<script lang="ts">
import { setContext } from 'svelte'
import B from './B.svelte'
// Proxy
const banana = $state({ value: '๐' })
setContext('banana', banana)
</script>
<input type="text" bind:value={banana.value} />
<B />
If you pass a string primitive, it wonโt be magically reactive:
<script lang="ts">
import { setContext } from 'svelte'
import B from './B.svelte'
let banana = $state('๐')
// ๐๏ธ this won't work
setContext('banana', banana)
</script>
<B />
This is because itโs going to use the value at the time it was created if we look at the compiled Svelte code:
import * as $ from 'internals'
// signal
let banana = $.state('๐')
// get the value of the signal
setContext('key', $.get(banana))
You can pass functions, classes or accessors to read and write to the value:
<script lang="ts">
import { setContext } from 'svelte'
import B from './B.svelte'
let banana = $state('๐')
// ๐ using functions
setContext('banana', {
getBanana() { return banana },
updateBanana(value) { banana = value },
})
// ๐ using classes
class Banana {
value = $state('๐')
}
setContext('banana', { banana: new Banana() })
// ๐ using accesors
setContext('banana', {
get banana() { return banana },
set banana(value) { banana = value },
})
</script>
<B />
Now you can access these methods in child components and update the context:
<script lang="ts">
import { getContext } from 'svelte'
// ๐ using accesors
const context = getContext<{ banana: string }>('banana')
</script>
<input type="text" bind:value={context.banana} />
{context.banana}
Use A Unique Key For Context
Letโs look at why using a string for the context key could get you into trouble and why you should use a unique key.
Hereโs an example of using a string for the context key:
<script lang="ts">
import { getAllContexts, setContext } from 'svelte'
// ๐ฃ๏ธ
const key = 'fruit'
setContext(key, '๐')
console.log(getAllContexts()) // Map(1) {'fruit' => '๐'}
</script>
The problem with using a string for the context key is if you set the context with the same key from another library or your own code, itโs going to overwrite the value:
<script lang="ts">
import { getAllContexts, setContext } from 'svelte'
const bananaKey = 'fruit'
setContext(bananaKey, '๐')
console.log(getAllContexts()) // Map(1) {'fruit' => '๐'}
// ๐ฅ oops
const appleKey = 'fruit'
setContext(appleKey, '๐')
console.log(getAllContexts()) // Map(1) {'fruit' => '๐'}
</script>
To avoid this, use a Symbol object to create a unique key:
<script lang="ts">
import { getAllContexts, setContext } from 'svelte'
const bananaKey = Symbol('fruit')
setContext(bananaKey, '๐')
console.log(getAllContexts()) // Map(1) {'fruit' => '๐'}
const appleKey = Symbol('fruit')
setContext(appleKey, '๐')
// Map(2) {Symbol(fruit) => '๐', Symbol(fruit) => '๐'}
console.log(getAllContexts())
</script>
This works because no two objects or Symbols are the same in JavaScript, but Symbols are more appropriate because they were made for this reason:
const objA = { key: 'fruit' }
const objB = { key: 'fruit' }
const symA = Symbol('fruit')
const symB = Symbol('fruit')
objA === objB // false
symA === symB // false
So if you donโt want your context to be overwritten by accident, use a unique key.
Encapsulating And Typing Context
You can encapsulate the context logic and provide better types instead of using setContext
and getContext
directly:
import { setContext, getContext } from 'svelte'
type Fruit = string
const key = Symbol('fruit')
export function setFruitContext(context: Fruit) {
setContext(key, context)
}
export function getFruitContext(): Fruit {
return getContext(key) as Fruit
}
Practical Example Of Using The Context API
You can find the code on GitHub.
I have a <Canvas>
and <Square>
component that need to talk to each other.
You have to bind the <Canvas>
component instance to canvas
and pass it to the <Square>
component so you can have access to it:
<script lang="ts">
import { Canvas, Square } from '$lib/canvas'
import { gradient } from '$lib/utils'
let canvas: ReturnType<typeof Canvas>
</script>
<Canvas bind:this={canvas} width={800} height={800}>
{#each Array(10) as _, col}
{#each Array(10) as _, row}
{@const size = 800 / 10}
{@const x = col * size}
{@const y = row * size}
{@const fillStyle = gradient(col, row)}
<Square {canvas} {x} {y} {size} {fillStyle} strokeStyle="#000" />
{/each}
{/each}
</Canvas>
The <Square>
component needs access to the addItem
function from the <Canvas>
component:
<script lang="ts">
import { type Snippet } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
type Props = { width: number; height: number; children?: Snippet }
type Draw = (ctx: CanvasRenderingContext2D) => void
let { width, height, children }: Props = $props()
let canvas: HTMLCanvasElement
let items = new SvelteSet<Draw>()
export function addItem(draw: Draw) {
$effect(() => {
items.add(draw)
// runs when destroyed
return () => items.delete(draw)
})
}
$effect(() => {
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, width, height)
items.forEach((draw) => draw(ctx))
})
</script>
<canvas bind:this={canvas} {width} {height}>
{@render children?.()}
</canvas>
Inside the <Square>
component, we need to use an effect to wait for the component to be ready and then add the draw
function to the items
set:
<script lang="ts">
import type Canvas from './Canvas.svelte'
type Props = {
canvas: ReturnType<typeof Canvas>
x: number
y: number
size: number
fillStyle?: string
strokeStyle?: string
}
let { canvas, x, y, size, fillStyle, strokeStyle }: Props = $props()
$effect(() => {
canvas?.addItem(draw)
})
function draw(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = fillStyle ?? ''
ctx.lineWidth = 2
ctx.strokeStyle = strokeStyle ?? ''
if (strokeStyle) {
ctx.strokeRect(x, y, size, size)
}
if (fillStyle) {
ctx.fillRect(x, y, size, size)
}
}
</script>
Letโs fix this by using the Context API:
import { setContext, getContext } from 'svelte'
type Draw = (ctx: CanvasRenderingContext2D) => void
type Canvas = { addItem: (draw: Draw) => void }
const canvasKey = Symbol('canvas')
export function setCanvasContext(context: Canvas) {
setContext(canvasKey, context)
}
export function getCanvasContext(): Canvas {
return getContext(canvasKey) as Canvas
}
Letโs update the <Canvas>
component and the <Square>
component to use the Context API:
<script lang="ts">
import { type Snippet } from 'svelte'
import { SvelteSet } from 'svelte/reactivity'
import { setCanvasContext } from './context'
type Props = { width: number; height: number; children?: Snippet }
type Draw = (ctx: CanvasRenderingContext2D) => void
let { width, height, children }: Props = $props()
let canvas: HTMLCanvasElement
let items = new SvelteSet<Draw>()
setCanvasContext({ addItem })
function addItem(draw: Draw) {
$effect(() => {
items.add(draw)
// runs when destroyed
return () => items.delete(draw)
})
}
$effect(() => {
const ctx = canvas.getContext('2d')!
ctx.clearRect(0, 0, width, height)
items.forEach((draw) => draw(ctx))
})
</script>
<canvas bind:this={canvas} {width} {height}>
{@render children?.()}
</canvas>
<script lang="ts">
import { getCanvasContext } from './context'
type Props = { x: number; y: number; size: number; fillStyle?: string; strokeStyle?: string }
let { x, y, size, fillStyle, strokeStyle }: Props = $props()
getCanvasContext().addItem(draw)
function draw(ctx: CanvasRenderingContext2D) {
ctx.fillStyle = fillStyle ?? ''
ctx.lineWidth = 2
ctx.strokeStyle = strokeStyle ?? ''
if (strokeStyle) {
ctx.strokeRect(x, y, size, size)
}
if (fillStyle) {
ctx.fillRect(x, y, size, size)
}
}
</script>
๐ช Letโs clean up the code where we use the <Canvas>
and <Square>
components:
<script lang="ts">
import { Canvas, Square } from '$lib/canvas'
import { gradient } from '$lib/utils'
</script>
<Canvas width={800} height={800}>
{#each Array(10) as _, col}
{#each Array(10) as _, row}
{@const size = 800 / 10}
{@const x = col * size}
{@const y = row * size}
{@const fillStyle = gradient(col, row)}
<Square {x} {y} {size} {fillStyle} strokeStyle="#000" />
{/each}
{/each}
</Canvas>
Thatโs it! ๐
The Context API is a powerful tool that can help you share state between deeply nested components, but itโs not a replacement for props and events, so use it only if you need it.