My Website

Search

    Using Svelte Runes to trigger sitewide actions

    Of course. I'm glad it's working for you! It's an excellent pattern for managing global state that needs to be displayed in a layout.

    Here is an overview of how the three files work together to create this reactive system.

    1. actionStore.svelte.ts (The Central Hub)

    • Purpose: This file acts as the single source of truth for your global actions. Because it's a .svelte.ts file, it can use Svelte 5 runes ($state, $derived) to create reactive state that is not tied to a specific component.
    • $state: It holds the raw, private state in _action = $state(...). This is the only piece of data that is directly changed.
    • $derived: It creates and exports several read-only values like actionSelected and cargoContactUs using $derived. These are like computed properties that automatically update whenever the _action state changes. This is very efficient because they only recalculate when their dependencies change.
    • trigger and reset functions: It exports functions that act as a public API for interacting with the state. Components can't change the state directly; they must call trigger() to set it or reset() to clear it. This creates a predictable, one-way data flow.

    2. +page.svelte (The Initiator)

    • Purpose: This component's job is to trigger a global action in response to a user interaction.
    • import { actionStore } ...: It imports the store.
    • onclick={handleContactUs}: When the user clicks the button, the handleContactUs function is called.
    • actionStore.trigger(...): This function call is the key. It tells the central actionStore to update its internal $state with a new action type and payload. This is the event that kicks off the entire reactive chain.

    3. +layout.svelte (The Display & Cleanup Crew)

    • Purpose: As a layout component, it's always present and wraps your pages. Its job is to display the current state from the actionStore and to clean up when the user navigates away.
    • Displaying State: In the HTML, it directly accesses the reactive properties from the store (e.g., {actionStore.actionSelected}). Because these are $derived values, Svelte automatically knows to update the DOM whenever they change. You don't need an $effect because rendering to the DOM is the effect.
    • afterNavigate: This SvelteKit lifecycle function is crucial. It runs every time you navigate to a new page. Inside it, we call actionStore.reset(). This clears the state, ensuring that when you navigate from /test1 to /test2, the "Contact Us" information from the first page disappears, giving you a clean slate.

    Summary of the Flow

    1. Click: A user clicks a button on a page (+page.svelte).
    2. Trigger: The page calls actionStore.trigger(), updating the central $state.
    3. Derive: The $derived values in actionStore automatically recalculate.
    4. Display: The +layout.svelte component sees the updated $derived values and re-renders the UI to show the new information.
    5. Navigate & Reset: The user clicks a link. After the new page loads, afterNavigate in +layout.svelte calls actionStore.reset(), clearing the display.

    [!question] I'm surprised you did this without $effect(). Tell me in a little more detail how the ripple-down reactivity occurs between the $state and the $derived values

    Of course. It's a great question because it gets to the heart of how Svelte 5's reactivity works. The absence of $effect here is a key feature of the new runes-based system.

    Think of it as Svelte building a dependency graph, or a "family tree" of reactive values, behind the scenes.

    1. The Source: $state

    When you declare const _action = $state(...), you are creating a source signal. This is the root of reactivity in your store. Svelte wraps this object and keeps track of it. Any time a property of _action is changed, Svelte knows that a fundamental piece of state has been updated.

    2. The Subscribers: $derived

    When you declare a $derived value, like const actionSelected = $derived(_action.type), Svelte does two things during initialization:

    1. It runs the expression once: It executes _action.type to get the initial value for actionSelected.
    2. It tracks dependencies: As it runs the expression, it notices that you read the _action signal. Because of this, it automatically establishes a parent-child relationship in its internal dependency graph: actionSelected is now a subscriber to _action.

    The same thing happens for cargoContactUs and cargoDownloadProducts. They also subscribe to _action.

    3. The "Ripple-Down" Propagation

    Here’s the step-by-step flow when you call actionStore.trigger():

    1. Write Operation: The trigger function modifies the source signal: _action.type = type;.
    2. Notification: The _action signal, upon being changed, immediately notifies all of its direct subscribers that its value is now "stale" or "dirty". It essentially tells actionSelected, cargoContactUs, and cargoDownloadProducts: "The data you depend on has changed. The next time someone asks for your value, you'll need to recalculate it."
    3. Read Operation (in the UI): Your +layout.svelte component is rendering {actionStore.actionSelected} in the template. This is a read operation. The Svelte runtime asks the actionSelected signal for its current value.
    4. Recalculation: The actionSelected signal, having been marked as "stale", now re-runs its expression (_action.type) to compute its new value. It then provides this fresh value to the layout for rendering.

    This entire process is what creates the "ripple-down" effect. A change to the $state source automatically invalidates its $derived children, which then recompute their values only when they are needed.

    The reason you don't need an $effect is that the ultimate side effect is rendering to the DOM, and Svelte's template engine handles that automatically by reading the $derived values. You only need $effect for side effects that don't involve rendering, like logging to the console or interacting with a non-Svelte API.

    [!question] While actionStore.svelte.ts isn't a class, node modules effectively have a similar ability to encapsuate data and behavior. actionStore could probably be written a class, but there is no payoff in doing that is there

    You are absolutely correct. While you could rewrite actionStore.svelte.ts as a class, there is no real payoff in doing so, and it would likely make the code more complex.

    The current module-based approach is the idiomatic and recommended way to handle this kind of global state in Svelte 5. Here's why it's the better choice:

    1. Simplicity and Less Boilerplate: The current code is very direct. You declare your state and the functions that operate on it. A class would require a constructor, this keywords, and you'd have to instantiate the class somewhere to create a singleton instance that the rest of your app could import. The module itself already acts as a natural singleton.

    2. Clearer Reactivity: With runes at the top level of a .svelte.ts module, the reactive graph is very clear. Svelte is designed to work seamlessly with this pattern. While you can use runes inside a class, it adds a layer of indirection that isn't necessary here.

    3. No this Context Issues: The module pattern avoids the complexities and potential pitfalls of managing the this context, which is a common source of bugs in JavaScript classes.

    In short, you've hit on a key design principle. The module provides the exact same encapsulation benefits as a class for this use case, but in a simpler, more functional, and more Svelte-native way. Stick with the current approach; it's clean, efficient, and correct.

    [!question] Would changing any of the three keys in the _action varaible fire the signal

    Yes, but with an important distinction that highlights how precise Svelte's reactivity is.

    Changing any property of the _action object will signal that the state has changed. However, a $derived value will only recalculate if a property it specifically depends on has changed.

    Let's break it down by key:

    • Changing _action.type: Yes, this will cause all three $derived values (actionSelected, cargoContactUs, and cargoDownloadProducts) to be re-evaluated because all of them read _action.type in their definitions.

    • Changing _action.cargo: Yes, this will cause cargoContactUs and cargoDownloadProducts to re-evaluate because they both read _action.cargo. However, actionSelected will not recalculate, because it only depends on _action.type.

    • Changing _action.key: This is the interesting one. Changing this property will signal a change in the _action object. However, since none of the current $derived values actually read _action.key, none of them will be recalculated. The change is registered, but it doesn't trigger a downstream update because no downstream values are subscribed to that specific property.

    This demonstrates the fine-grained nature of Svelte's reactivity. It doesn't just track objects; it tracks the specific properties of objects that are accessed, ensuring that updates are minimal and highly efficient.

    This is where the current version starts

    [!question] I thought it would be valulable to return strongly typed cargo types from actionStore. However, as the number of types grow, that might be unwieldy.. Could effective use of generics improve this

    That's a fantastic question. You've correctly identified a potential scaling issue with the current design. As you add more action types, creating a new $derived property for each one in actionStore.svelte.ts would indeed become unwieldy.

    Using generics, combined with a more advanced TypeScript pattern called a "discriminated union," is the perfect way to solve this. This approach will make your store more scalable, type-safe, and easier to use.

    Here is how we can refactor the code.

    1. Enhance actionTypes.ts

    First, we'll create a mapping between action type strings and their corresponding cargo shapes. This is the key to making the generics work. We'll also define a single, powerful Action type.

    // ...existing code...
    	subject: string;
    };
    
    export type DownloadProductsCargo = {
    	productId: string;
    	email: string;
    };
    
    /**
     * A map connecting ActionType strings to their cargo payload shapes.
     * This is the key to our generic solution.
     */
    export interface ActionCargoMap {
    	[ActionTypes.CONTACT_US]: ContactUsCargo;
    	[ActionTypes.DOWNLOAD_PRODUCT]: DownloadProductsCargo;
    }
    
    /**
     * A discriminated union of all possible action objects.
     * TypeScript can narrow this type based on the 'type' property.
     */
    export type Action = {
    	[K in keyof ActionCargoMap]: {
    		type: K;
    		cargo: ActionCargoMap[K];
    	};
    }[keyof ActionCargoMap];
    
    // --- Action Type Definitions ---
    
    /**
    // ...existing code...
    

    2. Refactor actionStore.svelte.ts

    Now, we'll simplify the store significantly. We no longer need multiple $derived properties for each cargo type. We'll have a single, strongly-typed action property.

    import { browser } from '$app/environment';
    import {
    	ActionTypes,
    	type Action,
    	type ActionCargoMap,
    	type ActionType
    } from '$lib/actionTypes';
    
    // --- Reactive State ---
    // The state is now a single, optional Action object.
    const _action = $state<Action | undefined>(undefined);
    
    // --- Derived Values (for consumers) ---
    // We only need to derive the action itself. Consumers will get the cargo from it.
    const action = $derived(_action);
    
    // --- Actions ---
    // The trigger function is now fully type-safe thanks to generics and ActionCargoMap.
    function trigger<T extends ActionType>(type: T, payload: ActionCargoMap[T]) {
    	if (browser) {
    		// We construct a valid Action object that matches our discriminated union.
    		_action = { type, cargo: payload } as Action;
    	}
    }
    
    function reset() {
    	_action = undefined;
    }
    
    // --- Exported Store Object ---
    export const actionStore = {
    	/**
    	 * The currently active action object. It is a discriminated union,
    	 * so you can check its 'type' property to get strong typing for 'cargo'.
    	 */
    	get action() {
    		return action;
    	},
    	trigger,
    	reset
    };
    

    3. Update +layout.svelte to Use the New Store

    Finally, update your layout to use the new, simpler store shape. The logic becomes much cleaner.

    // ...existing code...
    	});
    </script>
    
    <svelte:head>
    	<link rel="icon" href={favicon} />
    </svelte:head>
    
    <!-- We check if an action exists, then check its type -->
    {#if actionStore.action}
    	<div>{actionStore.action.type}</div>
    {/if}
    
    {@render children()}
    
    <!-- The 'if' block now acts as a type guard for actionStore.action.cargo -->
    {#if actionStore.action?.type === ActionTypes.CONTACT_US}
    	<h2>Contact us</h2>
    	<div data-contact-us>
    		<div>
    			<!-- TypeScript now knows this is ContactUsCargo -->
    			{actionStore.action.cargo.email}
    		</div>
    		<div>
    			{actionStore.action.cargo.subject}
    		</div>
    	</div>
    {/if}
    
    {#if actionStore.action?.type === ActionTypes.DOWNLOAD_PRODUCT}
    	<h2>Download Products</h2>
    	<div data-contact-us>
    		<div>
    			<!-- TypeScript now knows this is DownloadProductsCargo -->
    			{actionStore.action.cargo.email}
    		</div>
    		<div>
    			{actionStore.action.cargo.productId}
    		</div>
    	</div>
    {/if}
    

    How This Generic Approach Improves Things:

    1. Scalability: To add a new action, you only need to update actionTypes.ts (add the cargo type and update the ActionCargoMap). The store and layout components don't need to be changed at all.
    2. Strong Typing: When you call actionStore.trigger(ActionTypes.CONTACT_US, ...), TypeScript will now enforce that the second argument must be a valid ContactUsCargo object.
    3. Type Guards: In your Svelte components, checking actionStore.action.type allows TypeScript to automatically infer the correct type for actionStore.action.cargo within that {#if} block, eliminating the need for manual type assertions.

    [!question] Explain ActionCargoMap and Action

    Of course. This code creates a powerful and scalable type system for handling different kinds of actions. Here is a breakdown:

    ActionCargoMap

    This interface acts as a central registry or a "map". Its purpose is to connect a specific action type string (like 'CONTACT_US') to the exact TypeScript type of its corresponding data payload (ContactUsCargo).

    • Key: The action type string (e.g., ActionTypes.CONTACT_US).
    • Value: The shape of the data for that action (e.g., ContactUsCargo).

    By keeping this map, you have one place to define all possible actions and their data shapes.

    Action

    This is a discriminated union. It's a clever TypeScript pattern that creates a single type representing all possible action objects.

    It's constructed in two steps:

    1. [K in keyof ActionCargoMap]: { ... }: This is a mapped type. It iterates through each key (K) in ActionCargoMap and creates an object type for it. For example:
      • For the key 'CONTACT_US', it creates the type { type: 'CONTACT_US'; cargo: ContactUsCargo; }.
      • For the key 'DOWNLOAD_PRODUCT', it creates the type { type: 'DOWNLOAD_PRODUCT'; cargo: DownloadProductsCargo; }.
    2. { ... }[keyof ActionCargoMap]: This final part takes all the object types created in step 1 and combines them into a union.

    The final Action type is effectively: { type: 'CONTACT_US'; cargo: ContactUsCargo; } | { type: 'DOWNLOAD_PRODUCT'; cargo: DownloadProductsCargo; }

    The benefit is that when you have a variable of type Action, you can check the type property (the "discriminant"). TypeScript is smart enough to use that check to narrow down the type of the cargo property within that code block, giving you full type safety and autocompletion.

    [!question] Descriminating unions seem very powerful. Are they a top-tier TypeScript concept to understand?

    Yes, absolutely. Discriminated unions are one of the most powerful and important patterns in TypeScript. Mastering them is a key step in moving from basic to advanced TypeScript development.

    Here's why they are considered a top-tier concept:

    Enhanced Type Safety: They allow you to model a variable that can hold one of several distinct object shapes. By checking a common property (the "discriminant," which is type in your Action type), TypeScript can intelligently narrow the type to just one of those shapes, eliminating entire classes of bugs.

    Improved Code Clarity: They make your data structures more explicit and self-documenting. Instead of a single large object with many optional properties, you have a collection of well-defined, smaller types. This makes the code easier to read and reason about.

    Exhaustiveness Checking: This is a major benefit. When you use a switch statement on the discriminant property, TypeScript can warn you if you fail to handle every possible case in the union. This is incredibly valuable for maintenance, as it will immediately flag an error if you add a new action type but forget to update the logic that handles it.

    In your actionStore, the discriminated union is what allows the +layout.svelte component to safely access action.cargo.email or action.cargo.productId only within the correct {#if} block, with full confidence that the property will exist.

    Source signal

    In Svelte 5, a "source signal" is the fundamental reactive primitive, essentially an observable piece of data that, when updated, automatically notifies any "effects" (like component templates or $effect blocks) that depend on it, enabling fine-grained, efficient DOM updates without manual subscriptions, making reactivity automatic and component-level. It's the underlying mechanism for runes like $state and $derived, providing a dependency graph that wires up on the fly.

    Key Aspects of Signals in Svelte 5

    • Under-the-Hood: You don't typically interact with raw signals directly; runes like $state (for state) and $derived (for computed values) use them internally.
    • Automatic Subscriptions: When a signal (like a $state variable) is read inside an effect (like a template or $effect), the effect automatically subscribes to that signal.
    • Fine-Grained Updates: Changes trigger only the parts of the UI or logic that depend on them, leading to high performance.
    • Effects: These are functions that run in response to signal changes, handling side effects or updating the UI.
    • Derived State: $derived creates a new signal that automatically updates when its source signals change, acting like a reactive formula.

    Example

    <script>
      // $state(0) creates a source signal for 'count'
      let count = $state(0);
    
      // This template reads the 'count' signal, so it's an effect that subscribes
      // to 'count'. When 'count' changes, the UI updates.
    </script>
    
    <button on:click={() => count++}>
      Clicked {count} {count === 1 ? 'time' : 'times'}
    </button>
    

    When the button is clicked, count++ changes the signal's value, which automatically causes the template (the "effect") to re-render just that part of the DOM.