Skip to main content
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Dan G. Switzer, II
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Dan G. Switzer, II

Hello World In Svelte JS 4

By
Published in Comments (3)

After completing the Udemy course, Svelte.js Complete Guide by Maximilian Schwarzmüller, I wanted to build a small Svelte JS application for myself. It needed to be something complex enough to try a number of the features; but, not so complex that it would become a deterrent. I decided to build a JSON Explorer (much like the one I built in Angular 9). This small application uses a custom input, custom events, and recursive component rendering in Svelte 4.2.10.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The User Interface (UI) for this Svelte exploration is relatively simple. There's a form at the top into which you type or paste a JSON string. When you submit the form, that JSON string is parsed into a native JavaScript data structure, which is then recursively rendered to the screen. The rendering uses different colors for different data types. And, allows sub-trees within the rendering to be collapsed when you click on any of the labels.

There aren't that many Svelte components in this application. So, let's just look at them in turn, starting at the top.

The first component is the root component, App.svelte. This component serves two purposes. First, it orchestrates the data flow between the input form and the JSON object tree. Second, it encodes and persists the JSON payload to the browser URL so that you can refresh the page; or, share the URL with another person.

<script>

	// Import vendor modules.
	import { onMount } from "svelte";

	// Import app modules.
	import JsonError from "./ui/JsonError.svelte";
	import JsonExplorer from "./ui/JsonExplorer.svelte";
	import JsonInput from "./ui/JsonInput.svelte";

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	var parsingError = null;
	var parsingResult = null;
	var jsonPayload = "";

	// When the app is mounted, check to see if there is an initial payload encoded in the
	// URL. The base64-encoded hash makes it simple to share JSON explorations with other
	// people.
	onMount(
		() => {

			if ( jsonPayload = urlGet() ) {

				handleExplore();

			} else {

				// Setup a default value for funzies.
				jsonPayload = JSON.stringify({
					id: 4,
					name: "Esty Smith",
					activities: [ "movies", "tv", { which: "dinner", preference: "Good Stuff Diner" } ],
					relationship: {
						style: "platonic",
						isBFF: true,
						metadata: JSON.stringify({
							created: "2024-01-01 00:00:00",
							offset: "+04"
						})
					},
					referringFriend: null
				});

			}

		}
	);


	/**
	* I attempt to parse the current JSON payload and render it as an object tree.
	*/
	function handleExplore( event ) {

		parsingError = null;

		try {

			parsingResult = JSON.parse( jsonPayload );
			// Pretty-print the payload back into the input for easier editing.
			jsonPayload = JSON.stringify( parsingResult, null, 4 );
			// Persist the original value into the URL for sharing. I'm re-stringifying it
			// in order to always remove the extra whitespace that the pretty-printing
			// just added to the input (will matter the next time the Input is submitted).
			urlSet( JSON.stringify( parsingResult ) );


		} catch ( error ) {

			parsingError = error.message;

		}

	}


	/**
	* I try get the data persisted in the URL (otherwise returns empty string).
	*/
	function urlGet() {

		try {

			return( atob( window.location.hash.slice( 1 ) ) );

		} catch ( error ) {

			console.error( error );
			return( "" );

		}

	}


	/**
	* I try to persist the given data to the URL (fails silently).
	*/
	function urlSet( data ) {

		try {

			window.location.hash = btoa( data );

		} catch ( error ) {

			console.error( error );

		}

	}

</script>

<style>

	/**
	* It seems that there's no easy / intuitive way to apply to a CSS class to a custom
	* component. As such, we're going to wrap the custom elements with a DIV so that we
	* can apply some custom styling.
	*/
	.error-wrapper {
		margin-bottom: 20px ;
	}
	.input-wrapper {
		margin-bottom: 30px ;
	}
	.explorer-wrapper {
		margin-bottom: 30px ;
	}

</style>

<main>

	<h1>
		JSON Explorer In Svelte 4.2.10
	</h1>

	{#if parsingError}
		<div class="error-wrapper">
			<JsonError>
				{ parsingError }
			</JsonError>
		</div>
	{/if}

	<div class="input-wrapper">
		<JsonInput
			bind:value="{ jsonPayload }"
			on:explore="{ handleExplore }"
		/>
	</div>

	{#if parsingResult}
		<div class="explorer-wrapper">
			<JsonExplorer value="{ parsingResult }" />
		</div>
	{/if}

</main>

Notice that each one of my custom Svelte components is wrapped in a <div>. I don't love this; but, I also don't know how to better handle this. The problem here is that I wanted to apply a bottom-margin to the rendered elements; but, since each Svelte component isn't an actual "HTML tag"—just an abstraction—there's nothing to attach the style or class attribute to.

Aside: In Angular, I really love the fact that every component is an HTML tag. What you see is what you get. I find that makes it much easier to reason about. And, when you look at the rendered HTML, it matches the HTML in your component template. Which is great.

Other than this styling issue, I do appreciate how easy it is to pass data around. I especially love how easy the bind: directive makes it to create two-way data binding across custom Svelte components. bind:, combined with the export directive for exposing component properties, made it trivial to create a custom Input, JsonInput.svelte.

In the following Svelte component, I'm using the bind:value to create two-way data binding between the internal value property and the HTML textarea value. But, I'm also exporting the value property; which is what the root component is binding to with its version of the bind:value directive. This seamlessly creates a bidirectional data flow between the jsonPayload variable in the root component and the textarea value in this custom input.

Aside: In Angular, ngModel bindings automatically .trim() all emitted values. Coming from that background, it's seems unfortunate that I have to manually .trim() the value. At the very least, it would have been nice if there was a |trim modifier for bind, as in bind:value|trim={value}.

I also love in Angular that the ngForm[submit] directive automatically calls event.preventDefault() on the submit event. This is the only use-case that makes sense when you bind to the submit event.

<script>

	// Import vendor modules.
	import { createEventDispatcher } from "svelte";

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	export var value = "";
	var dispatch = createEventDispatcher();


	/**
	* I auto-submit the form if the user pressed CMD+Enter.
	*/
	function handleKeydown( event ) {

		if (
			( event.key === "Enter" ) &&
			( event.metaKey || event.ctrlKey )
			) {

			event.preventDefault();
			event.target.form.requestSubmit();

		}

	}


	/**
	* I emit the "explore" event for the given JSON value.
	*/
	function handleSubmit( event ) {

		if ( value.trim () ) {

			dispatch( "explore", value.trim() );

		}

	}

</script>

<style>

	form {
		display: flex ;
		font-size: 1.2rem ;
		gap: 10px ;
	}

	textarea {
		border: 1px solid #cccccc ;
		border-radius: 7px 7px 7px 7px ;
		display: block ;
		flex: 1 1 auto ;
		font-family: monospace ;
		font-size: inherit ;
		height: 150px ;
		padding: 10px 10px 10px 10px ;
		white-space: nowrap ;
		width: 100% ;
	}

	button {
		background-color: #e0e0e0 ;
		border: 1px solid #cccccc ;
		border-radius: 7px 7px 7px 7px ;
		cursor: pointer ;
		flex: 0 0 auto ;
		font-family: monospace ;
		font-size: inherit ;
		padding: 20px 30px 20px 30px ;
	}

</style>

<form on:submit|preventDefault="{ handleSubmit }">
	<textarea
		bind:value="{ value }"
		on:keydown="{ handleKeydown }"
	></textarea>
	<button type="submit">
		Explore
	</button>
</form>

When the encapsulated form is submitted, my custom Svelte input emits an explore event accompanied by the JSON text. The root component listens for this event (via the on:explore binding) and attempts to parse the JSON into a native data structure. If the parsing fails, an error message is rendered.

I encapsulated this error message rendering inside another Svelte component in order to look at slotted content projection. If you recall from the root component, the error message is rendered as such:

<JsonError>{ parsingError }</JsonError>

The body of the JsonError tag will be projected into the default <slot> element within the JsonError.svelte component:

<style>

	p {
		border: 1px solid #fc0000 ;
		border-left-width: 4px ;
		border-radius: 5px 5px 5px 5px ;
		margin: 0px 0px 0px 0px ;
		padding: 10px 15px 11px 15px ;
	}

</style>

<p>
	<strong>Oh no!</strong>
	<slot>
		There was an unexpected problem.
	</slot>
</p>

Internally, the JsonError.svelte component provides a default error message in case the calling context doesn't provide any child content. But, when the calling context does provide children, the contents of the <slot> element will be overridden with said children.

If the JSON parsing is successful, the resultant data structure will be passed into the JsonExplorer.svelte component. Rendering an arbitrarily-nested data structure requires recursion; and, this component does little more than initiate this recursion by rendering the root "node":

<script>

	// Import app modules.
	import JsonNode from "./JsonNode.svelte";

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	export var value = null;

</script>

<style>

	article {
		font-size: 1rem ;
	}

	.tree-wrapper {
		margin-bottom: 30px ;
	}

</style>

<article>
	<h2>
		Parsed Data Structure
	</h2>

	<div class="tree-wrapper">
		<JsonNode value="{ value }" />
	</div>

	<p>
		<strong>Pro Tip</strong>: If a String value contains JSON, you can try to parse
		it by using <strong>double-clicking</strong> on the value.
	</p>
</article>

As you can see, the JsonExplorer.svelte component simply takes the value property and pipes it into JsonNode.svelte. This JsonNode component will examine the given value, determine its type (ie, string, number, Boolean, object, array), and then recursively render itself as needed.

Internally, since Svelte uses import statements in order to define component elements, we need to use a special element for recursion: <svelte:self>. Simple values like strings, numbers, and Booleans represent our "base case". That is, they terminate a given recursive branch. Objects and arrays, on the other hand, contain multitudes—their entries and elements have to be rendered recursively via the <svelte:self> element.

There's a lot going on in the following Svelte code. Not only does this component render each type of data value, it also allows sub-trees of the rendering to be collapsed. And if a given data value is a string, this node will attempt to parse the given string as JSON if the user double-clicks on the value.

I could have probably collapsed the non-recursive renderings into a single case (with variable-based rendering); but, having everything explicitly broken apart feels easiest to reason about.

<script>

	// Import app modules.
	import { getType } from "./type-utils.js";
	import { ARRAY, BOOLEAN, NULL, NUMBER, OBJECT, STRING } from "./type-utils.js";

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	// I am the value being "explored".
	export var value = null;

	// I allow parts of the data structure tree to be collapsed.
	var isCollapsed = false;
	var collapsedEntries = Object.create( null );

	// Whenever the value changes, re-evaluate the type.
	$: valueType = getType( value );

	// ---
	// PRIVATE METHODS.
	// ---

	/**
	* I attempt to replace the current value with JSON.parse() of the current value. This
	* is helpful for when a string payload contains an embedded JSON representation.
	*/
	function parseStringValue( event ) {

		event.preventDefault();

		try {

			value = JSON.parse( value );

		} catch ( error ) {

			console.error( error );

		}

	}


	/**
	* I toggle the rendering of part of the data structure tree. For simple values, this
	* represents the entire node. For Objects/Arrays, this represents the entry in the
	* complex data structure.
	*/
	function toggle( event ) {

		var subIndex = event.target.dataset.toggleIndex;

		// Toggling the whole value.
		if ( subIndex === undefined ) {

			isCollapsed = ! isCollapsed;

		// Toggling a subvalue (index or key).
		} else {

			collapsedEntries[ subIndex ] = ! collapsedEntries[ subIndex ];

		}

	}

</script>

<style>

	.json-node {
		display: inline-grid ;
		grid-gap: 2px ;
	}

	.header {
		border: 1px solid #cccccc ;
		border-radius: 3px 3px 3px 3px ;
		background-color: #cccccc ;
		cursor: pointer ;
		font-family: inherit ;
		font-size: inherit ;
		font-weight: bold ;
		grid-column: 1 / span 2 ;
		line-height: inherit ;
		padding: 6px 6px 6px 6px ;
		text-align: left ;
	}
	.header__type {
		display: block ;
	}
	.header__count {
		display: block ;
		margin-top: 4px ;
	}

	.label {
		align-items: flex-start ;
		background-color: #cccccc ;
		border: 1px solid #cccccc ;
		border-radius: 3px 3px 3px 3px ;
		cursor: pointer ;
		display: flex ;
		font-family: inherit ;
		font-size: inherit ;
		grid-column: 1 / span 1 ;
		line-height: inherit ;
		padding: 6px 6px 6px 6px ;
		text-align: left ;
	}

	.value {
		background-color: #f0f0f0 ;
		border: 1px solid #cccccc ;
		border-radius: 3px 3px 3px 3px ;
		grid-column: 2 / span 1 ;
		padding: 6px 6px 6px 6px ;
	}

	.label.is-null {
		background-color: #d5d5d5 ;
		border-color: #999999 ;
		color: #000000 ;
	}
	.value.is-null {
		background-color: #fafafa ;
		border-color: #999999 ;
		color: #000000 ;
	}

	.label.is-boolean,
	.label.is-string,
	.label.is-number {
		background-color: #ffc900 ;
		border-color: #cca000 ;
		color: #000000 ;
	}
	.value.is-boolean,
	.value.is-string,
	.value.is-number {
		background-color: #fff4cc ;
		border-color: #cca000 ;
		color: #000000 ;
	}

	.header.is-array,
	.label.is-array {
		background-color: #2782dd ;
		border-color: #040f1a ;
		color: #ffffff ;
	}
	.value.is-array {
		background-color: #edf5fc ;
		border-color: #040f1a ;
	}

	.header.is-object,
	.label.is-object {
		background-color: #e81236 ;
		border-color: #130104 ;
		color: #ffffff ;
	}
	.value.is-object {
		background-color: #fde3e7 ;
		border-color: #130104 ;
	}

	.header.is-collapsed,
	.label.is-collapsed {
		background-color: #333333 ;
		border-color: #000000 ;
		color: #f0f0f0 ;
		font-style: italic ;
	}

	.header:hover,
	.label:hover {
		border-color: #ffffff ;
		outline: 1px solid #ffffff ;
		outline-offset: -3px ;
	}

</style>

<div class="json-node">

	{#if ( valueType === NULL ) }

		<button
			on:click="{ toggle }"
			class="label is-null"
			class:is-collapsed="{ isCollapsed }">
			Null
		</button>
		{#if ! isCollapsed }
			<div class="value is-null">
				null
			</div>
		{/if}

	{:else if ( valueType === STRING ) }

		<button
			on:click="{ toggle }"
			class="label is-string"
			class:is-collapsed="{ isCollapsed }">
			String
		</button>
		{#if ! isCollapsed }
			<a
				on:dblclick="{ parseStringValue }"
				class="value is-string">
				{ value }
			</a>
		{/if}

	{:else if ( valueType === BOOLEAN ) }

		<button
			on:click="{ toggle }"
			class="label is-boolean"
			class:is-collapsed="{ isCollapsed }">
			Boolean
		</button>
		{#if ! isCollapsed }
			<div class="value is-boolean">
				{ value }
			</div>
		{/if}

	{:else if ( valueType === NUMBER ) }

		<button
			on:click="{ toggle }"
			class="label is-number"
			class:is-collapsed="{ isCollapsed }">
			Number
		</button>
		{#if ! isCollapsed }
			<div class="value is-number">
				{ value }
			</div>
		{/if}

	{:else if ( valueType === ARRAY ) }

		<button
			on:click="{ toggle }"
			class="header is-array"
			class:is-collapsed="{ isCollapsed }">
			<span class="header__type">
				Array
			</span>
			<span class="header__count">
				Entries: { value.length }
			</span>
		</button>
		{#if ! isCollapsed }

			{#each value as subvalue, subvalueIndex }

				<button
					on:click="{ toggle }"
					data-toggle-index="{ subvalueIndex }"
					class="label is-array"
					class:is-collapsed="{ collapsedEntries[ subvalueIndex ] }">
					{ subvalueIndex }
				</button>
				{#if ! collapsedEntries[ subvalueIndex ] }
					<div class="value is-array">

						<!-- Recursively render array element. -->
						<svelte:self value="{ subvalue }" />

					</div>
				{/if}

			{/each}

		{/if}

	{:else if ( valueType === OBJECT ) }

		<button
			on:click="{ toggle }"
			class="header is-object"
			class:is-collapsed="{ isCollapsed }">
			<span class="header__type">
				Object
			</span>
			<span class="header__count">
				Entries: { Object.keys( value ).length }
			</span>
		</button>
		{#if ! isCollapsed }

			{#each Object.entries( value ) as [ subvalueIndex, subvalue ] }

				<button
					on:click="{ toggle }"
					data-toggle-index="{ subvalueIndex }"
					class="label is-object"
					class:is-collapsed="{ collapsedEntries[ subvalueIndex ] }">
					{ subvalueIndex }
				</button>
				{#if ! collapsedEntries[ subvalueIndex ] }
					<div class="value is-object">

						<!-- Recursively render object entry. -->
						<svelte:self value="{ subvalue }" />

					</div>
				{/if}

			{/each}

		{/if}

	{/if}

</div>

One thing that I really miss (when comparing Svelte to Angular) is how extremely simple Angular makes it to invoke methods from within a loop. If you look at the way the object and array child elements are being rendered, I'm including a data- attribute to define the index of the given child:

data-toggle-index="{ subvalueIndex }"

Then, my toggle() method is extracting this dataset value and is using it in order to collapse part of the data rendering. I'm doing this because Svelte 4 doesn't provide a native way to pass arguments into loop-based event-bindings. Meaning, I can't use something like this:

on:click="{ toggle( subvalueIndex ) }"

This code will actually invoke the expression, toggle(subvalueIndex), at render time—not at click time.

To get around this, developers can define an inline function expression which provides a Function instance that will invoke the toggle() function with the iteration-specific subvalueIndex argument:

on:click="{ ( event ) => toggle( subvalueIndex ) }"

But, for me personally, if I see a function expression inside an attribute, it's a "code smell". Something somewhere went wrong.

Aside: Thankfully, in my case, I was able to solve this each problem with a simple data- value. But, what if the each argument was a complex object that couldn't be serialized? I honestly don't know how I would have solved this without resorting to hacky code. I'm still learning Svelte.

For a better overview of how all of these Svelte components fit together, take a look at the video above.

This is my first time using Svelte. There are clearly some things that it makes very easy; and, some ways in which it makes things hard. Like all frameworks, it's a composite of various trade-offs.

Want to use code from this post? Check out the license.

Reader Comments

15,848 Comments

Hmmm, GitHub gists don't seem to want to color-code .svelte files properly. As such, I've renamed my filed to use .svelte.html extensions. GitHub seems to be happier with this.

238 Comments

I've been so curious to check out Svelte, but just haven't (yet) taken the time. Looks pretty clean to me! You've successfully whet my appetite...I hope you'll be sharing more of this!

15,848 Comments

@Chris,

There's definitely a lot to like in there. And this is Svelte 4. Apparently Svelte 5 is coming out early this year and will have a lot of big changes. I don't know too much about it, though. I'm sure I'll have more to say :)

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel