Svelte has become increasingly popular over the last several years, even being voted the “most loved” web development framework in the 2021 Stack Overflow Developer Survey. Quite a few articles have been written about how much nicer Svelte is to work with than React. Supposedly Svelte is easier to pick up for new developers and generally more pleasant to work with. But is it really?

I was pretty skeptical of the claims about Svelte. I’ve been developing React apps for a few years, and at this point, React seems very comfortable. Syntactically, it’s all just JavaScript and JSX. Components are functions that take props and return elements. Hooks can be a bit unintuitive at first, but most of React is fairly straightforward. Svelte, on the other hand, has lots of new syntax, with dollar signs, HTML directives, mustache-like template statements, and magically reactive stores. How can all that be easier to pick up than a JavaScript API?

I finally did give Svelte a try, and I found that it was easy to pick up, and it has a number of conveniences that can make front-end development easier than it is with React. There is some new syntax, but it’s usually relatively simple, and it really does make development more convenient in many cases.

The rest of this article will talk about what Svelte is and discuss how its developer experience compares to React in a few key areas. I won’t focus much here on the internals of Svelte or how its performance compares to React; suffice it to say that both of them are quite performant. Here we’re primarily concerned with what it’s like to create apps with Svelte.

What is Svelte?

Svelte occupies the same niche as React: it’s a component-based front-end web framework. Its feature set is generally similar to React’s, with APIs for declaring components, managing state, and interacting with the DOM, and not a lot beyond that. However, both the internal architecture and developer experience of Svelte are significantly different from React.

React operates primarily at runtime. It renders components to a virtual DOM (VDOM) and then updates the real DOM to match it. When some part of an app needs to be updated, the renderer will re-render the relevant part of the component tree, updating the VDOM and then adjusting the real DOM to match.

Svelte, on the other hand, operates primarily at build time. A compiler converts Svelte components into DOM-based JavaScript, similar to what a developer might produce if writing components by hand. There is no virtual DOM, and no overall renderer managing the entire UI. When some part of the app needs to update the DOM, it updates the DOM. This process usually results in smaller (but not always), faster code than would be produced in a similar React app.

Creating a project

The standard “create app” process is very similar for each:

~> npm create vite svelte-todo -- --template svelte-ts
~> npx create-react-app react-todo --template typescript

Both projects provide a dev server that will run locally and provide immediate feedback when the app code is changed, and both provide a build script that will generate a production build of the app. One thing that the React project includes that Svelte does not is a unit testing framework. While there are plenty of testing resources for Svelte apps, there isn’t currently an official default.

In terms of file layout, a simple Svelte project looks very similar to a simple React project. It has a src/ directory for the application source, a public/ directory for non-source assets, and some config files in the project root. One minor difference is that Svelte puts the index.html entry point in the project root rather than in the public/ directory.

Like React, Svelte has very good TypeScript support with both a standalone type checker and a language server for IDEs. Projects can include TypeScript source files, and TypeScript can be used in the script blocks in Svelte components (discussed more below).

Components

Component structure is probably the most obvious place where React and Svelte differ. A React component is a render function or a class with a render method.  There’s nothing special about either of these; they’re just JavaScript. In fact, a React component can be entirely standard JavaScript and can run directly in a browser, with no build step (JSX is convenient but optional). Props are passed in as function or constructor arguments, and the “output” of a component is a tree of render objects.

A simple React button component might look like this:

// Button.tsx
import './Button.css';
export default function Button(props: ButtonProps) {
  const { children, onClick, color = "#f0f0f0" } = props;
  return (
    <button
      style={{ backgroundColor: color }}
      onClick={onClick}
      class="Button"
    >
      {children}
    </button>
  );
}
// Button.css
.Button { color: #333; }

Using the button would look like this:

// App.tsx
import Button from './Button.tsx';
export default function App() {
  ...
  return (
    <div>
      </Button onClick={() => console.log('clicked')} color="#faa">
        Click me
      </Button>
    </div>
  );
}

Svelte components are much more closely aligned to files. Because a React component is just a standard JavaScript type (a function or class), a module may export multiple components. A .svelte component file, on the other hand, may only contain a single component, but it contains all the parts of the component (markup, scripts, and styles). A Svelte button component might look like this:

// Button.svelte
<script type="ts">
  export let color = "#f0f0f0";    // <-- this is a prop
</script>
<button style={`background-color: ${color}`} on:click><slot /></button>
<style>
  button { color: #333; }
</style>

Note that the snippet above is a complete .svelte component – no additional boilerplate is required. A component that uses the button might look like this:

// App.svelte
<script type="ts">
  import Button from './Button.svelte';
</script>

<div>
  <Button
    on:click={() => console.log('clicked')}
    color="#faa"
  >Click me</Button>
</div>

Although these two components are implementing the same thing, there are quite a few differences:

  • The React components look like TypeScript source files, while the Svelte components look more like HTML files. Svelte’s style attribute accepts a standard HTML-style string.
  • In general, the Svelte components have less boilerplate.
  • Svelte has special-purpose syntactic constructs, like the on:click directive and the <slot /> tag. React uses standard JavaScript and JSX.
  • Svelte props are declared using the export let syntax, while React props are passed in as arguments when the render function is called.

Svelte components are written in a superset of HTML. One interesting side effect of that is that existing HTML can be used pretty easily in Svelte components. For example, turning an SVG image into a Svelte component is as easy as renaming someFile.svg to someFile.svelte, and only minor changes are needed to make it accept props. Converting an SVG into a React component requires more extensive changes, often implemented with a third-party library or build-time tool because while JSX looks a bit like HTML, its syntax is different.

Props and Directives

While the syntax for passing props to a component will look very familiar when coming from React, Svelte’s syntax for declaring props looks odd:

<script>
  export let color = "#f0f0f0";
</script>

Although it’s valid JavaScript syntax, the semantics seem a bit backward; rather than exporting a value, the statement is providing a name for a value to be passed into the component. It’s a bit odd, but it makes conceptual sense given that Svelte components aren’t declared as callable entities like React components and so don’t have a natural way to accept arguments. There are still benefits to bundling all props into an object as React does, such as the ability to pass unknown props through to child components. Svelte supports this use case with the $props and $restProps variables that are implicitly available in components.

// Button.svelte
<button>{$props.label}</button>
// App.svelte
<Button label="Click me" />

The $props variable is an object containing all the props passed to a component, while $restProps is an object containing only the undeclared props. These two variables provide some flexibility in how a component handles props, but they should be used sparingly. For one, they’re not as explicit as declared props, and won’t be type-checked when using TypeScript. They are also not as easy for the Svelte compiler to analyze and optimize.

Along with normal attribute props, Svelte also has the concept of “directives”. A directive can be thought of as a prop with some additional information. The syntax for directives is:

<directive>:<target>[|<modifiers>][=<value>]

For example, the on: directive is used to listen for events on elements.

<button on:click={handleClick}>Click me</button>
<form on:submit|preventDefault={handleSubmit}>...</form>

There are directives for a variety of features, such as binding component properties to local variables and animating elements. While some directives are the standard way to handle certain features, like event handling or data binding, other directives are primarily conveniences, such as the style and class directives, which provide simpler and/or more controlled access to style properties.

Styles

One aspect of UI implementation where React doesn’t provide much assistance is styling. React supports a style attribute for components, and it works with standard CSS via the className prop, and that’s it. The CRA project template supports CSS modules, and the community has created a number of styling options, many based on CSS-in-JS, but there is no officially recommended option other than standard CSS. React’s style attribute can also be a bit confusing to developers used to HTML and CSS since it only accepts an object and requires CSS property names to be converted to camel-case.

Svelte is a bit more helpful out-of-the-box for general component styling. Styles are included in component files and CSS rules are automatically scoped. The Svelte compiler and language tools will also detect unused styles.

<h1>Hello, world!</h1>
<p class="content">This is a test.</p>

<style>
  h1 { color: green; }
  .content { color: blue; }
  a { text-decoration: none; }
</style>

In the component above, the “h1” rule is scoped to this component, so other h1 elements on the page won’t be affected. Similarly, the “content” style is scoped to the component. The Svelte dev tools will warn that the “a” rule is unused.

Svelte also supports SCSS, Stylus, LESS, and PostCSS via the svelte-preprocess package, which is included by default with new Svelte projects. Simply add the relevant processor to the project and use a “lang” attribute on style blocks:

<style lang="scss">
  .outer {
    .inner { background: green; }
  }
</style>

Props

Along with the component-level <style> block, Svelte components have standard props for applying CSS classes (class) and inline styles (style). The class prop works like React’s className prop and the HTML class attribute, taking a string of class names.

Svelte’s style prop, on the other hand, is different from React’s. Whereas React’s style prop accepts an object where the property names are camel-cased versions of CSS properties, Svelte’s style prop works like the HTML style attribute, accepting a string of inline CSS styles.

Directives

Svelte provides a couple of directives that can make applying styles and classes easier.

The class directive allows for individual styles to be applied based on a boolean expression.

<script type="ts">
  export let success: boolean;
  export let message: string;
</script>
<div class="message" class:success={success}>{message}</div>
<style>
  .message { padding: 1em; border: solid 1px #ccc };
  .success { background: #afa };
</style>

In the snippet above, the “success” CSS class will be applied if the value of success is true.

When the condition is a boolean variable with the same name as the conditional class, Svelte even allows the condition to be omitted:

<div class="message" class:success>{message}</div>

Svelte also has a style directive that lets inline style properties be handled individually. This can be simpler than constructing a style string, and like the class directive, the style directive has an abbreviated syntax when using a variable with the same name as the style property.

<script type="ts">
  export let color: string;
  export let message: string;
</script>
<div style:color style:background-color="#f0f0f0">{message}</div>

Styling child components

Because Svelte styles are automatically scoped, CSS classes can’t be directly passed to child components. Consider a situation where you have a Header component and would like to apply some custom styles. A common pattern in React with CSS is for a component to take a className or extraClasses prop. A parent component will define the custom style and pass the class name to the child. The child will simply add the custom class to its own default class name, and the relevant style rules will be applied.

// App.tsx
<Header className="section-header">Hello!</Header>

// Header.tsx
export default function Header(props: HeaderProps) {
  let headerClass = "Header";
  if (props.className) {
    headerClass = `${headerClass} ${props.className}`;
  }
  return <h1 className={headerClass}>{props.children}</h1>;
}

Svelte’s philosophy is that styles should be encapsulated. Any styles that a parent wants to apply to a child component should be intentionally accepted by the child, not just implicitly applied through a passed in class name. That means that style changes coming into a component need to use explicit APIs (e.g., a component could expose a “color” prop), use the :global() style escape hatch, or be CSS variables.

In the explicit API approach, a parent uses an API defined by a child component. For example, a child component could expose a color prop. The parent could provide a value for color, but the child would actually decide if and how to apply that to itself.

<ChildComponent color="green" />

The :global() directive declares a style that will not be scoped by Svelte’s compiler, so styles applied to the global class name in the Svelte component will be applied to exactly that class name in the compiled app. In the snippet below, CSS rules will be created for a “success” class rather than a scoped version of it. When a child component uses the “success” class, any style rules declared with :global() will apply to it. The downside, of course, is that those rules will apply to any element in the document with a “success” class, not just the targeted child.

<ChildComponent extraClass="success" />
<style>
  :global(.success) {
    color: green;
  }
</style>

CSS variables in Svelte work as they do with normal CSS. Values can be set like other styles, using the style prop or directive. Svelte also provides a convenient syntax for CSS variables that lets them be set directly as props.

<ChildComponent style="--color:green; --background:white;" />
<ChildComponent style:--color="green" style:--background="white" />
<ChildComponent --color="green" --background="white" />

Managing state

Like React, Svelte supports component-based state values. It also has some built-in features that make sharing states between components very easy.

State variables

React’s APIs for the component states are the useState and useReducer hooks, or the state property in class components. These are all very explicit APIs. State is held in a variable and updated using a setter function. Calling the setter triggers a re-render of the component.

// Counter.tsx
export default function Counter() {
  const [count, setCount] = useState(0);
  console.log(`rendering with count ${count}`);
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

State variables in Svelte can feel a bit magical when you’re used to React. The counter component above written in Svelte would look like this:

// Counter.svelte
<script>
  let count = 0;
  $: console.log(count);
</script>
<button on:click={() => count++}>{count}</button>

There’s nothing special about the count variable here, at least not as it’s written in the component. The Svelte compiler can see that count is being displayed, and that it’s being updated, and it will build the necessary functionality into the component to update the label of the button when count is changed.

One thing that does look a bit weird is the dollar sign ($) in front of the console.log statement. This is a standard JavaScript label; it’s a valid language feature, but one that’s not used much in modern JavaScript. Its purpose here is to tell the Svelte compiler that the console.log statement should be reactive.

By default, the code in a <script> block is only run once, when the component is initialized. (Svelte components don’t have a render cycle like React components, so component code won’t be automatically run again when state changes.) The dollar sign label tells Svelte that the following statement should be re-run whenever one of the state variables it references is updated, just as the DOM parts of the component are updated whenever a state variable changes. The reactive statement may also be a block, like $: { ... }, in which case the entire block will be rerun whenever a referenced state variable is updated.

Context

React contexts are data objects shared by a “provider” component that can be accessed by descendant components. They are primarily used to pass data between components without prop drilling. They work a bit like a state prop — when the context value is updated (by passing a new value to the provider), any components accessing the context value will be re-rendered.

Svelte also has contexts that serve a similar purpose to React contexts: they provide a way for a component to share data with its descendants without prop drilling. The API is much simpler than React’s, with just setContext and getContext functions. A component creates a context with a key and a value:

setContext('theme', { background: 'white', text: 'black' });

Any descendant component may access the context via the key:

const theme = getContext('theme');

A key difference from React’s contexts is that Svelte contexts are not reactive. Updating a context value does not trigger updates in any components using the context. Svelte provides another API when reactivity is required: stores.

Stores

React’s built-in state management features are mostly focused on individual components. The standard methods for state sharing are lifting state to a parent component or contexts. Both of these have drawbacks; the lifting state can require prop drilling to get it back down to descendant components, and contexts can lead to unnecessary renders if not used carefully.

Svelte has contexts, but its more general purpose answer to state sharing is the store. A store is similar to an RxJS observable; code can subscribe to it to be notified when the store’s value changes.

Typically components won’t explicitly subscribe to a store. Instead, they’ll use reactive syntax to access the store, which is just syntactic sugar around the subscribe and update processes. The code below demonstrates how two components can use a writable store to share a list of random values.

// src/lib/stores.ts
import { writable } from 'svelte/store';
export const values = writable<string[]>([]);
// src/lib/Values.svelte
<script type="ts">
  import { values } from '$lib/stores';
</script>
<ul class="values">
  {#each $values as value}
    <li>{value}</li>
  {/each}
</ul>
// src/lib/Status.svelte
<script type="ts">
  import { values } from '$lib/stores';
</script>
<div class="status">Displaying {$values.length} values</div>
<button on:click={() => $values = [...$values, Math.random()]}>Add 
  value</button>
// src/App.svelte
<script type="ts">
  import Messages from '$lib/Values.svelte';
  import Status from '$lib/Status.svelte';
</script>
<main>
  <Todos />
  <Status />
</main>

In the example code, a values store is created to hold a list of random values. Two components use the store, a Values component that displays the values in a list, and a Status component that displays the total value count and a button to add a new value. The $ used when accessing the messages store is syntactic sugar; it allows the store’s data to be transparently accessed (messages isn’t actually an array of strings, it’s an observable store instance), and it also creates a subscription to the store so that store updates will trigger renders in the same way the $: label creates a reactive statement.

Note that the click action for the “Add message” button is using an assignment rather than $messages.push. This is because the Svelte compiler detects updates by looking for assignments. Mutations will update the store, but not in a reactive way; nothing will notice the update until some other event causes data to be read from the store again.

Data binding

One Svelte feature with no direct analog in React is data binding. Svelte has a bind directive that can be used to bind local variables to component properties. For example, the following component contains an <input> with its value bound to a local variable:

<script type="ts">
  let inputVal = '';
</script>
<input bind:value={inputVal} />

The inputVal variable is bound to the value attribute of the <input> element. Whenever the input’s value is updated, inputVal will also be updated. This binding is two-way; if inputVal is updated in the component, the value of the <input> will also be updated. The <button> in the snippet below, if added to the component above, will clear the input value when clicked:

<button on:click={() => inputVal = ''}>Clear</button>

As with some of the other directives, Svelte provides a convenient syntax when an attribute is being bound to a variable with the same name:

<script type="ts">
  let value = '';
</script>
<input bind:value />

In React, the same functionality would typically be implemented with a state variable and an update function, like:

const [value, setValue] = useState('');
return <input value={value} onChange={setValue} />

Interestingly, the bind directive can also be used to bind to an element itself:

<script type="ts">
  let input: HTMLInputElement;
</script>
<input bind:this={input} />

In this mode, bind works like a React ref prop. The input variable will end up with a reference to the <input> DOM element. This type of binding also works for custom elements. To make a useful ref for a custom React component, one would typically wrap the component in forwardRef and use useImperativeHandle to define the ref’s functionality:

// MyInput.tsx
export const MyInput = forwardRef((props, ref) => {
  const inputRef = useRef();
  useImperativeHandle(() => {
    focus: () => { inputRef.current.focus(); }
  });
  return <input ref={inputRef} />;
});
// App.tsx
import { MyInput } from './MyInput.tsx
export const App = (props) => {
  const inputRef = useRef();
  const focusInput = () => {
    inputRef.current.focus();
  };
  return <MyInput ref={inputRef} />;
};

In Svelte, you’d use bindings:

// MyInput.svelte
<script type="ts">
  let input: HTMLInputElement;
  export function focus() {
    input.focus();
  }
</script>
<input bind:this={input} />
// App.svelte
<script type="ts">
  import MyInput from './MyInput.svelte';
  let input: MyInput;
  function focusInput() {
    input.focus();
  }
</script>
<MyInput bind:this={input} />

The Svelte version isn’t dramatically shorter, but it is simpler and likely easier to figure out for a less experienced developer.

Conclusion

Svelte and React are alike in many ways. They’re both component frameworks with similar capabilities and they work with many of the same concepts. Both can be used to create rich, performant apps. However, Svelte gives up some syntactic simplicity to provide developers with quite a few developer conveniences; this can make the process of creating apps with Svelte a more pleasant experience than with React.