Svelte is so much simpler than React

In this post, I'm going to show some examples of common functionality written in both React and Svelte.

As the State of JS put it, Svelte is “quickly establishing itself as a serious contender for the front-end crown”. When I read those words, I was working on a side project in React and started to wonder what the same app would look like in Svelte. Luckily, I had a free weekend, so I decided to try it out. What I found was impressive.

Handling input changes

To control an <input> field in React, you need to import useState, destructure the variables off of your state hook, set the value, and handle changes on it:

import React, { useState } from 'react'

export default function Example() {
const [value, setValue] = useState("")

return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>

)
}

In Svelte, you can bind a variable to an <input>:

<script>
let value = ""
</script>

<input bind:value />

Side effects

In React, side effects are handled through useEffect. For instance, if you want to update the page title every time a variable changes:

import React, { useState, useEffect } from 'react'

export default function Example() {
const [count, setCount] = useState(0)

useEffect(() => {
document.title = count
}, [count])

function add() {
setCount(prev => prev + 1)
}

return <button onClick={add}>Click me!</button>
}

Instead of useEffect, Svelte uses $ to denote reactive statements. Values which directly appear within the $: block become the dependencies (in this case, count), so you don’t need to list them manually.

<script>
let count = 0

$: document.title = count

function add() {
count = count + 1
}
</script>

<button on:click={add}>Click me!</button>

Managing the document head

In React, we usually manage the document’s <head> with a third-party package called react-helmet:

import React from 'react'
import { Helmet } from 'react-helmet'

export default function Example() {
return (
<div>
<Helmet>
<link rel="canonical" href="https://x.com" />
</Helmet>

...
</div>
)
}

Svelte comes out-of-the-box with <svelte:head>.

<svelte:head>
<link rel="canonical" href="https://x.com" />
</svelte:head>

<div>...</div>

Applying a class based on a condition

If someCondition is truthy, let’s apply the CSS class “active”:

<div className={someCondition ? "active" : ""}>
<div class:active={someCondition}>

You can shorten this even more if the variable name is the same as the class you want to apply:

<div className={active ? "active" : ""}>
<div class:active>

If you want to add a conditional class to a component alongside other classes, you don’t need to join the strings together — you can add the two class attributes next to each other.

<div class="p-4 bg-red rounded-lg" class:foo />

Locally-scoped CSS

In React, you can’t locally scope CSS without a third-party package like CSS Modules:

import React from 'react'
import styles from './Example.module.css'

export default function Example() {
return (
<button className={styles.button}>Click me!</button>
)
}
/* Example.css */
.button {
background: red;
}

In .svelte files, styles are automatically scoped to the component they’re in. This doesn’t only apply to classes, but also tags, so you don’t even need to apply a class. All of the code above is condensed into this:

<button>Click me!</button>

<style>
button {
background: red;
}
</style>

Assigning props

In Svelte, if your prop is the same name as your variable, you can collapse it:

<User username={username} status={status} avatar={avatar} />
<User {username} {status} {avatar} />

Referencing DOM nodes

In React, you’d use ref to reference a DOM node – for instance, to get its height:

import React, { useRef } from 'react'

export default function Example() {
const button = useRef(null)

function getHeight() {
alert(button.current.offsetHeight)
}

return (
<button ref={button} onClick={getHeight}>
Get height
</button>
)
}

In Svelte, you can use bind:this to reference the DOM node:

<script>
let button

function getHeight() {
alert(button.offsetHeight)
}
</script>

<button bind:this={button} on:click={getHeight}>
Get height
</button>

Event listeners

In React, you need to manage event listeners on the window inside of a useEffect, and remove them when the component unmounts.

import React, { useEffect } from 'react'

export default function Example() {
useEffect(() => {
function logKey(e) {
console.log(e.key)
}

window.addEventListener("keydown", logKey)

return () => window.removeEventListener("keydown", logKey)
}, [])

return null
}

In Svelte, <svelte:window> will automatically manage this for you:

<script>
function logKey(e) {
console.log(e.key)
}
</script>

<svelte:window on:keydown={logKey} />

Svelte also offers <svelte:body> to add event listeners to document.body.

Debouncing inputs

Here’s the most straightforward-but-reusable way I’ve found to debounce an input in both React and Svelte. The resulting APIs are a bit different, but the purpose is to run a function 500ms after the user stops typing. In React, that looks like this:

import React, { useState, useEffect } from 'react'
import useDebounce from './useDebounce'

export default function Example() {
const [value, setValue] = useState("")
const debouncedValue = useDebounce(value, 500);

useEffect(() => {
const searchResults = fetch(`/search?q=${debouncedValue}`)
},
[debouncedValue])

return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>

)
}
// useDebounce.js
import { useState, useEffect } from 'react'

export default function useDebounce(value, duration) {
const [debouncedValue, setDebouncedValue] = useState(value)

useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), duration)
return () => clearTimeout(handler)
}, [value, duration])

return debouncedValue
}

In Svelte, we can create a debounce function and apply it to an element with the word use. This is called a “use directive”.

<script>
import debounce from './debounce'

let value

function search() {
const searchResults = fetch(`/search?q=${value}`)
}
</script>

<input
bind:value
use:debounce={{ value, func: search, duration: 500 }}
/>
// debounce.js
export default function debounce(node, params) {
let timer

return {
update() {
clearTimeout(timer)
timer = setTimeout(params.func, params.duration)
},
destroy() {
clearTimeout(timer)
}
}
}

If you didn’t care about making it reusable, you could write it like this:

<script>
let value = ""
let timer

$: value && debouncedSearch()

function debouncedSearch() {
clearTimeout(timer)
timer = setTimeout(search, 500)
}

function search() {
const searchResults = fetch(`/search?q=${value}`)
}
</script>

<input bind:value>

Children

Passing children into a component:

import React from 'react'

export default function Example({ children }) {
return <div>{children}</div>
}
<div>
<slot />
</div>

Running code on mount

In React, you can run code directly after a component has been mounted via a useEffect with an empty dependency array.

import React, { useEffect } from 'react'

export default function Example() {
useEffect(() => {
console.log("Mounted!")
}, [])

return <div />
}

In Svelte, you can use onMount, which I think is a lot more readable than useEffect(() => { ... }, []).

<script>
import { onMount } from 'svelte'

onMount(() => {
console.log("Mounted!")
})
</script>

<div />

Svelte also offers beforeUpdate, afterUpdate, and onDestroy (which runs immediately before the component is unmounted).

preventDefault

If you don’t want a form to reload the page on submit, you can use e.preventDefault(). In React, you need to add this to your form handler function:

import React from 'react'

export default function Example() {
function handleSubmit (e) {
e.preventDefault()
alert("Submitted!")
}

return (
<form onSubmit={handleSubmit}>...</form>
)
}

In Svelte, you can do this inline with a |:

<script>
function handleSubmit() {
alert("Submitted!")
}
</script>

<form on:submit|preventDefault={handleSubmit}>...</form>

You can also do this with stopPropagation (to prevent the event from bubbling to the next element), once (to remove the handler after the first time it runs), and a few others. I like that this increases the signal-to-noise ratio of the handler function.

Loading data

When you want to make an external request in React, you need to manage the data, loading state, and errors yourself. Here’s the non-reducer version:

import React, { useState, useEffect } from 'react'

export default function RandomWord() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [word, setWord] = useState("")

useEffect(() => {
async function fetchData() {
const response = await fetch(`/random-word`)
const result = await response.text()

if (!response.ok) {
setError(result)
} else {
setWord(result)
}

setLoading(false)
}

fetchData()
}, [])

if (loading) {
return <>Loading...</>
}

if (error) {
return <>An error occurred: {error}</>
}

return <>Your word is "{word}"</>
}

In Svelte, you can use an {#await} block. Loading and error states will be handled for you:

<script>	
let promise = fetchData()

async function fetchData() {
const response = await fetch(`/random-word`)
const result = await response.text()

if (!response.ok) {
throw new Error(result)
}

return result
}
</script>

{#await promise}
Loading...
{:then word}
Your word is "{word}"
{:catch error}
Error: {error.message}
{/await}

Stores

Here’s a little bonus! Svelte also comes out-of-the-box with stores. I think their docs explain it best:

Not all application state belongs inside your application’s component hierarchy. Sometimes, you’ll have values that need to be accessed by multiple unrelated components, or by a regular JavaScript module.

React doesn’t have a first-party equivalent here (although you can set it up yourself or with third-party packages), but I wanted to highlight the simplicity of Svelte’s API.

Here’s what it looks like to set up a store that holds a number:

import { writable } from 'svelte/store'

export const count = writable(0)

To read or write to this store from inside of a component, prefix the store’s name with $. Let’s increment the number when a button is clicked.

<script>
import { count } from './my-store'

function increment() {
$count = $count + 1
}
</script>

<button on:click={increment}>Increment!</button>

This would update $count across all of the other components using the store, as well as in standalone JavaScript modules. For instance, you could use a store to hold a user object and reference it in a plain .js file! You could even update the value from this file, and it would update across all of your components. Not only that, but you could have a store retrieve from local storage when the page is first loaded.

Svelte also supports readable and derived stores. You can learn more about them here.

Learn more

That’s the end for now! If this post piqued your interest and you want to learn more about Svelte, there are a few things I’d suggest: watching this talk by Rich Harris (the creator), reading their docs, trying their interactive tutorial, or using Vite’s project generator to create a Svelte app of your own:

# for npm 7+
npm init @vitejs/app my-svelte-app -- --template svelte

About the author

I'm Mark Thomas Miller, a full stack engineer and designer currently working at ConvertKit. (We're hiring!) People like Arnold Schwarzenegger, Lindsey Stirling, and Tim Ferriss use features I've built to connect with their fans. I'm currently geeking out about Svelte, mechanical keyboards, and minimalist UI design, and replaying Ocarina of Time.