Skip to main content
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Jason Dean and Simon Free
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Jason Dean Simon Free

Code Kata: Building A Tri-State Switch In Alpine.js

By
Published in Comments (2)

At work, I've been building a bulk-export feature for user prototypes. In the export experience, you can enable and disable "hotspot hinting". But, it's not exactly a binary experience. Meaning, I wanted to include a partially on state in which the hotspot hints were initial invisible; but, which would flash briefly when the user clicked on the prototype screen and failed to make contact with a transparent hotspot. To facilitate this configuration, I created a tri-state switch / toggle component:

Showing various hotspot hinting behaviors associated with a tri-state switch. When the switch is fully-on, the hotspot hints are always visible. When the switch is fully-off, the hotspot hints are never visible. And, when the switch is partially-on, the hotspots hints flash when the user clicks on the screen but fails to click on a hotspot.

This export is built in legacy Angular.js. As a code kota, I wanted to see if I could build the same kind of tri-state switch using Alpine.js. The primary challenge with Alpine.js, in this context, is that it doesn't really have a sense of "component inputs". But, we can use the x-effect directive to propagate changes in the parent scope down into the state of our switch component.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The way in which I architected my tri-state toggle requires both a set of states and a selected state to be passed in. Internally, the tri-state switch component then maps those inputs onto a "phase" of the switch: on, off, and partial. This mapping happens inside a reconcile() method which is invoked via x-effect:

<div
	x-data="TriSwitch()"
	x-effect="reconcile({
		states: options,
		state: selected
	})">
</div>

In this case, the options and selected values are being provided by the parent scope. And, anytime they change, Alpine.js will detect the change and reactively invoke the reconcile() method on the TriState() component instance, passing-in the latest object as defined within the x-effect expression.

The TriState() component then calculates the index of the given state reference within the given states array and recalculates the internal "phase" of the tri-state toggle. The code for the reconcile() method is very small:

// Inputs are being passed-in via ex-effect.
function reconcile( inputs ) {

	// this.phases == [ "off", "partial", "on" ]
	this.phase = (
		this.phases[ inputs.states.indexOf( inputs.state ) ] ||
		this.phases[ 0 ]
	);

}

By using x-effect to bind the parent scope to the (tri-state switch) child scope, the child component doesn't have to know anything about the parent component. To see this in action, I've created a demo in which the parent component has three possible modes: None, Some, and All, which will be mapped onto the off, partial, and on of the tri-state toggle using the reconcile() method above:

Three buttons are being used to change the state of a component, which is then being used to change the state of a tri-state toggle.

Here's the full HTML and Alpine.js code for this demo:

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
	<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body>

	<h1>
		Creating A Tri-State Switch In Alpine.js
	</h1>

	<div x-data="Demo">
		<p>
			<button
				@click="setMode( 'None' )"
				:class="{ active: ( selected === 'None' ) }">
				None
			</button>
			<button
				@click="setMode( 'Some' )"
				:class="{ active: ( selected === 'Some' ) }">
				Some
			</button>
			<button
				@click="setMode( 'All' )"
				:class="{ active: ( selected === 'All' ) }">
				All
			</button>
		</p>

		<!--
			The x-effect directive works by re-evaluating the overall expression every
			time one of the embedded dependencies changes. As such, any time either the
			"options" or the "selected" parent state is updated, the tri-switch
			component's reconcile() method will be invoked and the updated state will be
			passed-in, allowing the internal tri-switch state to be mapped from the parent
			scope state (ie, one-way data binding).
		-->
		<div
			x-data="TriSwitch()"
			x-effect="reconcile({
				states: options,
				state: selected
			})"
			@click="cycleMode()"
			class="tri-switch"
			:class="{
				on: ( phase === 'on' ),
				off: ( phase === 'off' ),
				partial: ( phase === 'partial' )
			}">
			<div class="tri-switch__track">
				<div class="tri-switch__thumb"></div>
			</div>
		</div>

	</div>

	<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>
	<script type="text/javascript">

		function Demo() {

			return {
				options: [ "None", "Some", "All" ],
				selected: "Some",

				// Public methods.
				cycleMode,
				setMode,
			};

			// ---
			// PUBLIC METHODS.
			// ---

			/**
			* I cycle to the next selectable mode.
			*/
			function cycleMode() {

				var selectedIndex = this.options.indexOf( this.selected );

				// If the NEXT index is undefined, circle back to the front of the modes.
				if ( this.options[ ++selectedIndex ] === undefined ) {

					selectedIndex = 0;

				}

				this.selected = this.options[ selectedIndex ];

			}

			/**
			* I set the selected mode.
			*/
			function setMode( newMode ) {

				this.selected = newMode;

			}

		}

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

		function TriSwitch() {

			return {
				phases: [ "off", "partial", "on" ],
				phase: "off",

				// Public methods.
				reconcile,
			};

			// ---
			// PUBLIC METHODS.
			// ---

			/**
			* I reconcile the parent scope state with the local input bindings.
			*/
			function reconcile( inputs ) {

				this.phase = (
					this.phases[ inputs.states.indexOf( inputs.state ) ] ||
					this.phases[ 0 ]
				);

			}

		}

	</script>

</body>
</html>

This was fun to figure out, but it definitely feels like it cuts against the grain of what Alpine.js is really good at (which is binding to event handlers and then updating dynamic attributes). Every time I try to wrap more encapsulated logic into a reusable component, it leaves me wanting for something more substantial like Angular.

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

Reader Comments

1 Comments

How unbelievably difficult it is to do what is essentially a variable that fits in two bits with still 25% memory wasted should tell us something about how awful the framework is we work in.

You have a setter, a getter, a cycle function, a conditional, an array overflow error handler, a class, an instance of said class, and a ton of boiler plate on top. That's astonishingly complicated for what you get.

This is all the pseudocode you need:

states = [ on, off, partial ]
state = states[(state +1 % 3)]

And you turned it into a monster.

15,848 Comments

@Samuel,

I'll admit there's a lot of code here; but, it's not as obvious to me (as you make it out to be). I had to make some decisions that I'm not completely confident in.

For example, with a simple Boolean switch, the fact that there are only 2 states makes things much simpler. Cycling direction matter; and, there's no need to pass-in any additional data since True/False are the only possible options.

When it comes 3+ states, I tried to build-in more customization. For example, you mention the need (or lack of need) of a "cycle" method. But, if the tri-state switch is in state "2 of 3", should cycling move forward to state 3? Or backward to state 1? I don't think there's a correct answer. So, I chose to defer that to the calling context.

Then, I could have passed-in the state index as a property (ex, state="1" (or 2 or 3); but then I felt like that wasn't a great developer experience since now the calling context had to handle the indexOf() stuff. So, I chose to instead pass in the states and state, and then have the toggle component try to work out the index.

There's also stuff here that is somewhat "leaky" because Alpine.js doesn't have great encapsulation since it's designed to work on top of server-rendered HTML. If this were something like Angular, which has strong encapsulation, at the very least the HTML markup would have been much simpler.

All to say, there were almost certainly different choices that could have been made. But, I think they all likely have pros-and-cons.

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