Sharing State Without Props And Events In Svelte

Published Nov 22, 2024

Table of Contents

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:

example
<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:

example
<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>:

passing-props
<!-- 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:

src/routes/context-at-home.ts
// 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:

using-context
<!-- 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:

svelte-context
<!-- 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.

Svelte component tree using the Context API

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:

config.ts
// 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:

src/routes/A.svelte
<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:

src/routes/A.svelte
<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:

output
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:

src/routes/A.svelte
<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:

src/routes/D.svelte
<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:

example
<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:

example
<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:

example
<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:

example
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:

src/routes/context.ts
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:

src/routes/+page.svelte
<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:

src/lib/canvas/Canvas.svelte
<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:

src/lib/canvas/Square.svelte
<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:

src/lib/canvas/context.ts
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:

src/lib/canvas/Canvas.svelte
<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>
src/lib/canvas/Square.svelte
<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:

src/routes/+page.svelte
<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.

Support

You can subscribe on YouTube, or consider becoming a patron if you want to support my work.

Patreon
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub