Svelte for the React Developer: A Practical Look

Svelte takes a fundamentally different approach to building user interfaces than React. Instead of doing much of its work in the browser at runtime, Svelte shifts that effort to a compile step. This has a ripple effect on everything from state management to styling. For a React developer, the mental model shifts are significant, but the payoff is a more direct, less verbose way to write components.

Anatomy of a Component

A Svelte component lives in a .svelte file and is processed by a Rollup or webpack plugin. The structure is familiar, but the roles of each section are different from a React component.

<script>
  let number = 0;
</script>

<style>
  h1 {
    color: blue;
  }
</style>

<h1>Value: {number}</h1>

<button on:click={() => number++}>Increment</button>
<button on:click={() => number--}>Decrement</button> 

A component has three distinct parts: a <script> block for logic, a <style> block for CSS, and the component's markup. The styles in that <style> tag are automatically scoped to the component—there's no need for CSS Modules or styled-components. If you write h1 { color: blue; }, it only applies to <h1> elements in that specific component.

The markup itself supports templating syntax with {#if} blocks and {#each} loops. This can initially look like a step backward from React's "JavaScript everywhere" approach. However, Svelte allows arbitrary JavaScript expressions inside these binding tags, which makes them far more flexible than they first appear.

{#if childSubjects?.length}

The deeper difference is in how updates happen. React re-runs an entire component function (and often its ancestors) when state changes, relying on hooks like useMemo and useCallback to prevent wasteful re-calculations. Svelte's compiler instead analyzes your template to find precisely which parts of the DOM depend on which state variables. When a variable changes, it generates code to update only those specific DOM nodes directly. In practice, this means you rarely think about memoization; Svelte only re-runs the code that needs to run.

State and Stores

React's useState hook returns a tuple: the current value and a setter function. Svelte doesn't need that ceremony. You declare a variable and assign to it to update it.

<script>
  let number = 0;
</script>

<h1>Value: {number}</h1>
<button on:click={() => number++}>Increment</button>
<button on:click={() => number--}>Decrement</button>

Svelte's ahead-of-time compilation tracks those assignments and updates the DOM at the relevant points. This also means there's no need for a single wrapping element like JSX requires—Svelte has no equivalent of the fragment syntax because it's simply not necessary.

When you need to pass an updater function to a child, you can write it explicitly.

<script>
  import Component3a from "./Component3a.svelte";
        
  let number = 0;
  const setNumber = cb => number = cb(number);
</script>

<h1>Value: {number}</h1>

<button on:click={() => setNumber(val => val + 1)}>Increment</button>
<button on:click={() => setNumber(val => val - 1)}>Decrement</button>

Svelte doesn't have a direct equivalent to useReducer, but it does have a more general-purpose primitive called a store. A writable store is an object holding a value, with a set method and an update method that takes a callback, just like React's state setters. A get function reads the current value at any moment, and the store's subscribe method accepts a callback that fires on every change.

Inside components, the syntax is dramatically simplified. Prefixing a store variable with $ subscribes to it and reads its value directly, while a plain assignment updates it.

<script>
  import { writable, derived } from "svelte/store";
        
  let writableStore = writable(0);
  let doubleValue = derived(writableStore, $val => $val * 2);
        
  writableStore.subscribe(val => console.log("current value", val));
  doubleValue.subscribe(val => console.log("double value", val))
</script>

<h1>Value: {$writableStore}</h1>

<!-- manually use update -->
<button on:click={() => writableStore.update(val => val + 1)}>Increment</button>
<!-- use the $ shortcut -->
<button on:click={() => $writableStore--}>Decrement</button>

<br />

Double the value is {$doubleValue}

This example also shows how derived stores project one or more stores into a single new value.

Because stores are plain objects, they can be passed to children, combined, and made read-only (by exporting only a derived version). This flexibility means you can even recreate the React APIs you know. Mimicking useReducer is straightforward: it's a function that takes a reducer and an initial value, and returns a store alongside a dispatch function that applies the reducer to the current value.

export function useReducer(reducer, initialState) {
  const state = writable(initialState);
  const dispatch = (action) =>
    state.update(currentState => reducer(currentState, action));
  const readableState = derived(state, ($state) => $state);

  return [readableState, dispatch];
}

The usage is nearly identical as in React, aside from the $ prefix used to read the store's value.

<script>
  import { useReducer } from "./useReducer";
        
  function reducer(currentValue, action) {
    switch (action) {
      case "INC":
        return currentValue + 1;
      case "DEC":
        return currentValue - 1;
    }
  }
  const [number, dispatch] = useReducer(reducer, 0);      
</script>

<h1>Value: {$number}</h1>

<button on:click={() => dispatch("INC")}>Increment</button>
<button on:click={() => dispatch("DEC")}>Decrement</button>

Implementing a useState equivalent is an equally valid exercise that shows off the same flexibility, though it's usually not a necessary abstraction in normal Svelte usage.

Two-Way Binding

React's data flow is strictly one-way: callbacks are passed down so children can notify parents. Svelte supports that pattern fully, but it also offers an opt-in two-way binding that can cut out boilerplate. Components declare props with the export keyword.

<!-- Child.svelte -->
<script>
  export let val = 0;
</script>

<button on:click={() => val++}>
  Increment
</button>

Child: {val}

A parent can then use the bind: directive to tie one of its own variables to that child's val prop, keeping them in sync automatically.

<!-- Parent.svelte -->
<script>
  import Child from "./Child.svelte";
        
  let parentVal;
</script>

<Child bind:val={parentVal} />
Parent Val: {parentVal}

The parent's parentVal updates whenever the child modifies its own val. It's a controversial feature for some, and it's entirely optional, but in the right places it removes a lot of wiring.

Side Effects and Actions

React's useEffect requires you to specify a dependency array. Accidental omissions lead to stale closures. In Svelte, the near-equivalent is a reactive statement, which uses the $: prefix. Svelte inspects the variables in the block and re-runs it only when those specific dependencies change.

$: {
  console.log("number changed", number);
}

Writing a cleanup function manually works as well.

let cleanup;
$: {
  cleanup?.();
  console.log("number changed", number);
  cleanup = () => console.log("cleanup from number change");
}

Re-assignments inside the block won't cause an infinite loop. For cleanup tied to a component's lifecycle, Svelte has the more explicit onMount and onDestroy functions.

The most powerful tool in this area is the action. Actions handle side effects that are tied to a specific DOM node's lifecycle. If you need to attach a jQuery plugin or set up a ResizeObserver when an element enters the DOM, and tear it down when it leaves, you use the use: directive.

{#if show}
  <div use:myAction>
    Hello                
  </div>
{/if}

The action is just a function that receives the DOM node.

function myAction(node) {
  console.log("Node added", node);
}

You can return a cleanup function from that action, and it will run when the node is removed from the DOM.

function myAction(node) {
  console.log("Node added", node);

  return {
    destroy() {
      console.log("Destroyed");
    }
  };
}

Actions can also accept arguments, passed as the second parameter to your function.

<div use:myAction={number}>
  Hello                
</div>
function myAction(node, param) {
  console.log("Node added", node, param);

  return {
    destroy() {
      console.log("Destroyed");
    }
  };
}

If the argument ever changes, Svelte calls an optional update function you can return from the action. For multiple input values, you pass them inside an object, and Svelte re-triggers the update function whenever any property of that object changes.

<div use:myAction={{number, otherValue}}>
  Hello                
</div>

Additional Built-ins

Several features Svelte ships out of the box have no direct React counterpart. Form bindings are built in, along with CSS helper classes. Animation support is included, with spring physics and enter/exit transitions as first-class citizens. The concept of React.Children is replaced by slots, which can be named, and tend to be easier to reason about.

One of the most notable compile-time features is the ability to generate true web components. Setting the tagName property in svelte:options enables this, and you'll need to set a corresponding property in your module bundler's configuration.

{
  loader: "svelte-loader",
  options: {
    customElement: true
  }
}

For anyone coming from React, these features—targeted compilation, native scoped styling, ergonomic stores, actions, and built-in animation—address many of the rough edges that require third-party libraries or careful discipline elsewhere. Combined, they make Svelte's development model both expressive and refreshingly direct.