Recursive JSON Explorer In Alpine.js 3.13.5
When I'm learning a new front-end JavaScript framework, I like to try building a JSON data structure explorer. I've built one in Angular 9; and, just recently in Svelte 4. It's a fun project because it's small enough in scope to be doable; but, it's complex enough in its functionality to make it a true learning experience. I've recently unblocked some technical limitations in Alpine.js. And so, I wanted to try building a JSON explorer in Alpine.js 3.13.5.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
The main technical blocker to building a JSON explorer in Alpine.js is recursion. Out of the box, Alpine.js has no in-built answer to recursive rendering; which is what is needed when rendering an arbitrarily complex data structure.
To overcome this limitation, I borrowed the idea of a "template outlet" from Angular. A template outlet takes a template reference and renders it using a given "context" (data mapping). In an Alpine.js application, I can implement this template outlet using a custom directive. And, once I could arbitrarily clone and bind x-data
to a template, I was finally able to recursively render templates in Alpine.js.
As I was building this demo, I ran into a few smaller hurdles. Fist, Alpine.js templates can only have a single root element. Which means, if you have this template definition:
<template x-if="true">
<strong>Hello:</strong>
<span>World</span>
</template>
When Alpine.js renders this template, it will only render the <strong>
element; and will completely omit the <span>
element without error or warning. This is because, internally, it uses the .firstElementChild
property when consuming the cloned content.
Normally, this isn't a big problem. However, in my JSON explorer, I need to use CSS Grid to manage the layout so that the elements all stretch to the necessary height as the data structure unrolls into the DOM (Document Object Model). And, if I start wrapping elements in order to adhere to the "one root element" restriction, I start breaking my CSS grid layout.
To work within these constraints, I created a throw-away HTML element, <template-root>
, with display: contents
:
<style type="text/css">
template-root {
display: contents ;
}
</style>
<template x-if="true">
<template-root>
<strong>Hello:</strong>
<span>World</span>
</template-root>
</template>
The display: contents
property tells the browser to essentially ignore the box-model for a given element and treat the nested content as if it were a direct decedent of the parent element. In other words, it kind of tells the browser to ignore the given element and to render the layout as if the element didn't really exist. Which is exactly what I need - it fulfills the Alpine.js requirement of a single root element without breaking the flow of the CSS grid cells.
The other hurdle I ran into was re-rendering the JSON data structure tree after the initial rendering. Meaning, if I rendered one JSON parsing result; and then changed the JSON payload and re-parsed it, the tree rendering wouldn't update. I tried to solve this with a $watch()
binding; but, I couldn't get it to work. I don't think this is a limitation in Alpine.js - I think this is a limitation in my understanding of the framework.
To get around this, I use the Alpine.nextTick()
method to leave a "tick" in between the nullification of the parsing result and the re-assignment of the parsing result:
/**
* I parse the form input into a data structure.
*/
function exploreJson() {
// Reset the data structure.
this.parsingError = null;
this.parsingResult = undefined;
// Give Alpine.js a chance to blow-away the current DOM rendering of the
// parsingResult value. Then, once the DOM is cleared (in the next tick),
// re-parse the JSON and re-assign the parsingResult.
Alpine.nextTick(
() => {
try {
this.parsingResult = JSON.parse( this.form.jsonPayload );
} catch ( error ) {
this.parsingError = error.message;
}
}
);
}
This .nextTick()
usage is a bit of a brute-force approach; and, it causes the DOM to "flash" in between subsequent renderings of the JSON payload. But, it's not so bad that I wanted to make it a blocker for this solution.
With that said, here's my implementation of a recursive JSON explorer in Alpine.js. There's a good deal of code here (with some of it located in external files) that I won't explore in depth. I'll leave it up to you to dig in as much as you want.
That said, I am using some custom Alpine.js directives that I created in previous posts. Namely, the x-cx
directive for CSS class names and the x-meta-enter
directive for CMD+Enter
form submission.
<!doctype html>
<html lang="en">
<link rel="stylesheet" type="text/css" href="./main.css" />
<body>
<h1>
Recursive JSON Explorer In Alpine.js 3.13.5
</h1>
<div x-data="AppController">
<template x-if="parsingError">
<p x-text="parsingError"></p>
</template>
<form @submit.prevent="exploreJson()" class="json-form">
<textarea
x-model="form.jsonPayload"
x-meta-enter="exploreJson()"
></textarea>
<button type="submit">
Explore JSON
</button>
</form>
<!-- START: JSON Tree. -->
<template x-if="( parsingResult !== undefined )">
<section>
<h2>
Parsed Data Structure
</h2>
<!--
This will kick-off the recursive rendering of the parsed data
structure. Each value in the structure will be rendered by a
materialized instance of this template.
-->
<template
x-template-outlet="$refs.nodeTemplate"
x-data="{ value: parsingResult }">
</template>
<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>
</section>
</template>
<!-- END: JSON Tree. -->
<!-- START: JSON Node (Recursive rendering ahead). -->
<template x-ref="nodeTemplate">
<!--
CAUTION: This x-data binding expects a "value" to be defined by the
recursive template-outlet rendering.
-->
<div x-data="JsonNodeController" class="json-node">
<!--
Note that in (almost) all of the templates, I am using a made-up
element "<template-root>". This is a byproduct of the fact that every
template in Alpine.js can only have a single root element. This root
element does nothing (in my case) other than have "display:contents"
such that the "display:grid" can skip over this root element while
still adhering to Alpine's structural requirements.
-->
<template x-if="( valueType === NULL )">
<template-root>
<button
@click="toggle()"
class="label is-null"
x-cx:is-collapsed="isCollapsed">
Null
</button>
<div
x-show="( ! isCollapsed )"
class="value is-null">
null
</div>
</template-root>
</template>
<!-- END: Null value. -->
<template x-if="( valueType === STRING )">
<template-root>
<button
@click="toggle()"
class="label is-string"
x-cx:is-collapsed="isCollapsed">
String
</button>
<a
x-show="( ! isCollapsed )"
@dblclick="parseStringValue( $event )"
class="value is-string"
x-text="value">
</a>
</template-root>
</template>
<!-- END: String value. -->
<template x-if="( valueType === BOOLEAN )">
<template-root>
<button
@click="toggle()"
class="label is-boolean"
x-cx:is-collapsed="isCollapsed">
Boolean
</button>
<div
x-show="( ! isCollapsed )"
class="value is-boolean"
x-text="value">
</div>
</template-root>
</template>
<!-- END: Boolean value. -->
<template x-if="( valueType === NUMBER )">
<template-root>
<button
@click="toggle()"
class="label is-number"
x-cx:is-collapsed="isCollapsed">
Number
</button>
<div
x-show="( ! isCollapsed )"
class="value is-number"
x-text="value">
</div>
</template-root>
</template>
<!-- END: Number value. -->
<template x-if="( valueType === ARRAY )">
<template-root>
<button
@click="toggle()"
class="header is-array"
x-cx:is-collapsed="isCollapsed">
<span class="header__type">
Array
</span>
<span class="header__count">
Entries: <span x-text="value.length"></span>
</span>
</button>
<template x-if="( ! isCollapsed )">
<template x-for="( subValue, subKey ) in value">
<template-root>
<button
@click="toggle( subKey )"
class="label is-array"
x-cx:is-collapsed="collapsedEntries[ subKey ]"
x-text="subKey">
</button>
<template x-if="( ! collapsedEntries[ subKey ] )">
<div class="value is-array">
<!--
RECURSIVE RENDERING: Note that I'm mapping
the (subValue -> value) when rendering the
array element.
-->
<template
x-template-outlet="$refs.nodeTemplate"
x-data="{ value: subValue }">
</template>
</div>
</template>
</template-root>
</template>
</template>
</template-root>
</template>
<!-- END: Array value. -->
<template x-if="( valueType === OBJECT )">
<template-root>
<button
@click="toggle()"
class="header is-object"
x-cx:is-collapsed="isCollapsed">
<span class="header__type">
Object
</span>
<span class="header__count">
Entries: <span x-text="Object.keys( value ).length"></span>
</span>
</button>
<template x-if="( ! isCollapsed )">
<template x-for="( subValue, subKey ) in value">
<template-root>
<button
@click="toggle( subKey )"
class="label is-object"
x-cx:is-collapsed="collapsedEntries[ subKey ]"
x-text="subKey">
</button>
<template x-if="( ! collapsedEntries[ subKey ] )">
<div class="value is-object">
<!--
RECURSIVE RENDERING: Note that I'm mapping
the (subValue -> value) when rendering the
array element.
-->
<template
x-template-outlet="$refs.nodeTemplate"
x-data="{ value: subValue }">
</template>
</div>
</template>
</template-root>
</template>
</template>
</template-root>
</template>
<!-- END: Object value. -->
</div>
</template>
<!-- END: JSON Node. -->
</div>
<!-- Include my custom directives. -->
<script type="text/javascript" src="./util.js" defer></script>
<script type="text/javascript" src="./alpine.class-names.js" defer></script>
<script type="text/javascript" src="./alpine.meta-enter.js" defer></script>
<script type="text/javascript" src="./alpine.template-outlet.js" defer></script>
<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.js" defer></script>
<script type="text/javascript">
document.addEventListener(
"alpine:init",
function setupAlpineBindings() {
Alpine.data( "AppController", AppController );
Alpine.data( "JsonNodeController", JsonNodeController );
}
);
/**
* I control the app component.
*/
function AppController() {
return {
parsingError: null,
parsingResult: undefined,
form: {
jsonPayload: ""
},
exploreJson: exploreJson,
init: init
};
// ---
// PUBLIC METHODS.
// ---
/**
* I parse the form input into a data structure.
*/
function exploreJson() {
this.parsingError = null;
this.parsingResult = undefined;
if ( ! this.form.jsonPayload.trim() ) {
return;
}
// CAUTION: Since the recursive template rendering maps sub-values onto
// values, we can't just re-render the root - it won't propagate properly.
// Instead, we have to set the result to null (above), give the DOM a tick
// to be destroyed, and then re-render the root in order to initiate a
// fresh start.
Alpine.nextTick(
() => {
try {
this.parsingResult = JSON.parse( this.form.jsonPayload.trim() );
// Pretty-print the payload back into the input for easier editing.
this.form.jsonPayload = JSON.stringify( this.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).
hashStore.set( JSON.stringify( this.parsingResult ) );
} catch ( error ) {
this.parsingError = error.message;
}
}
);
}
/**
* I initialize the app component.
*/
function init() {
if ( this.form.jsonPayload = hashStore.get() ) {
this.exploreJson();
} else {
// Setup a default value for funzies.
this.form.jsonPayload = JSON.stringify({
id: 4,
name: "Kim Dory",
activities: [
"movies",
"tv",
{ which: "dinner", preference: "Good Stuff Diner" }
],
relationship: {
style: "platonic",
isBFF: true,
// Embed a JSON payload in order to demonstrate parsing.
metadata: JSON.stringify({
created: "2024-01-01 00:00:00",
offset: "+04"
})
},
referringFriend: null
});
}
}
}
/**
* I control the JOSN Node component (which is recursively rendered).
*/
function JsonNodeController() {
return {
isCollapsed: false,
collapsedEntries: Object.create( null ),
valueType: getType( this.$data.value ),
parseStringValue: parseStringValue,
toggle: toggle
};
// ---
// PUBLIC 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 {
// CAUTION: This will override the value locally to the node. But,
// this value will be blown-away if the current node is collapsed and
// then expanded.
this.value = JSON.parse( this.value );
this.valueType = getType( this.value );
console.group( "String Parsing" );
console.log( `The value was successfully parsed as JSON (${ this.valueType }).` );
console.log( this.value );
console.groupEnd();
} catch ( error ) {
console.group( "String Parsing" );
console.warn( "The value could not be parsed as JSON." );
console.error( error );
console.log( this.value );
console.groupEnd();
}
}
/**
* I toggle the rendering of the current value or the given sub-value.
*/
function toggle( subkey ) {
// Collapsing the root value.
if ( subkey === undefined ) {
this.isCollapsed = ! this.isCollapsed;
// Collapsing the sub-value.
} else {
this.collapsedEntries[ subkey ] = ! this.collapsedEntries[ subkey ];
}
}
}
</script>
</body>
</html>
Again, there's a lot of code here - review it at your leisure. That said, if we run this Alpine.js application and render the default JSON payload, we get the following recursively rendered data structure tree:
As you can see, we are able to take a JSON payload, parse it into an arbitrarily complex data structure, and then recursively render the data structure using Alpine.js.
Want to use code from this post? Check out the license.
Reader Comments
I solved the root re-rendering (when changing some nested property) problem by basically destroying the tree and reconstructing it using the same logic in the template directive. For me, this was only an issue when part of the tree is deleted so I decided to reconstruct the whole tree from root when deleting any node. That's how I avoided the flashing.
@Angelez,
Yeah, I think that makes the most sense. At first, I tried to work around it by passing-down a
valueProperty
instead of avalue
; and then, trying to setup a$watch(valueProperty)
callback that would populate the component-local value. The thought being that the$watch()
would help propagate changes.But, I couldn't get it to work. Some dots just weren't connecting in my head. The "blow away the whole tree and start again" just worked well and kept the code simple (relatively speaking).
I honestly like your solution more than mine. Yours is simple and when you're nuking the whole tree, who wouldn't expect to have some flashing?
@Angelez,
Exactly - I don't love the flashing. But, I'm OK with the flashing if it means simpler code :)
Hey, I also wanted thank you for writing this. I'm not a great "front ender", your
template-root
trick is awesome. I was using divs everywhere!My pleasure! The
<template-root>
thing is frustrating. And, it usesdisplay:contents
(to remove it from the document box model), which does have some issues. I'm not too familiar with the scope of the issues, but I believe they mostly relate to ARIA / accessibility stuff. If you look at CanIUse.com, you'll see that nothing has 100% support for it, but that it's all stuff that I don't think matters in our particular scenario.In the end, wrapping stuff in
div
s is totally fine as well (heck, that's what every React app in the world does). I just liked making something that felt more expressive of what my intent was.Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →