AstroJAM Stack

SERIES: Learn Astro while building Ink #4

Astro & Interactivity - Bring your Site to Life

Aftab Alam

Aftab AlamSeptember 09, 2021

12 min read–––

Aftab Alam

You're on part 4 of the 5-part series Learn Astro while building Ink where you'll build a blog(Ink) using astro.build, one of the most promising static/JAM Stack site builder today!

4

Astro & Interactivity - Bring your Site to Life

Astro(.astro) components are supposed to be rendered statically to string/HTML. They natively or directly cannot support interactivity or client-side/user-oriented behavior. Anything that has anything to do with interactivity - carousels, accordion, AJAX-based forms, and remote data-driven panels, you won't be able to achieve purely with *.astro files.

This constraint is important, as well as necessary

  • For fine-grained control over a component initialization process,
  • Introduce interactivity
  • Not be interactive by default, but delegate it to the more mature set of tools, specialized and exceptionally well at it, and
  • Do things that have no parallel when a methodology of outputting HTML is at play(.astro)

.astro wants to ship less to no JS by default, and as long as you stick with the format, you'll get what it promises.

You get the promise - Ship less JS by default now? :-)

So, how can you do it?

If you need interactivity - You can rely on your good friends from the UI ecosystem like React, Svelte, and Vue (even Web Components).

Using components from the other UI Frameworks.

Astro is all ready to embrace .jsx, .vue, .svelte files, but considering that you'll always need either one or all of them isn't a decision that should stay with your SSG Framework. Astro lets you enable, the framework of your choice through a compile-time, framework-agnostic constructs called Renderers. Renederes for React, Vue, Svelte, etc. ship by default and can be enabled using the renderers key in astro.config.mjs file. For example, if you'll have Svelte components alongside Astro files, you can enable it by adding the following line in astro.config.mjs

astro.config.mjs
  //
  renderers: [
    '@astrojs/renderer-svelte',
  ]

Similarly, React, Vue, and Preact doesn't require any external modules to be installed and can be enabled simply by adding one or all of the following config

astro.config.mjs
export default {
  renderers: [
    // Add the framework renderers that you want to enable for your project.
    // If you set an empty array here, no UI frameworks will work.
    //  '@astrojs/renderer-svelte',
    //  '@astrojs/renderer-vue',
    //  '@astrojs/renderer-react',
    //  '@astrojs/renderer-preact',
  ],
};

So, What renderers precisely do?

Your Framework specific renderers are supposed to perform two duties

  1. render a component to a static string of HTML at build time
  2. rehydrate that HTML to create an interactive component on the client. (we'll learn this soon)

For frameworks or needs outside the supported list of Frameworks(or Web Components), the config could be customized as -

astro.config.mjs
export default {
  renderers: ['my-custom-renderer'],
};

Coding Time

Since this article is about interactivity, we'll create something interactive, but we'd try something simple enough to demonstrate the concept. How about a theme switcher? A button on the client-side that can change the theme for our site?

Choose the Framework

We'll choose Svelte as our framework due to the following reasons

  • It's light-weight, and has one of the lowest framework size/runtime cost amongst the known/popular frameworks
  • the syntax does not takes you away too far from HTML and native JavaScript, plus

Svelte feels like a close cousin to Astro, with constructs like

  • Single File Components (script, HTML, style) all in the same File
  • <slot/> works just as it works in the *.astro files

    Trivia - Some of the initial work on Astro took its inspiration from Svelte. At one point, props also worked as they work in Svelte today.

Make Astro aware of Svelte

Let's enable the svelte renderer as shown before.

astro.config.mjs
export default {
  renderers: [
    '@astrojs/renderer-svelte',
  ],
};

Create a non-Astro(Svelte) component

the ModeSwitcher Svelte component (src/components/ModeSwitcher.svelte) we want to create will

  • maintain two string values of ThemeType - dark and light to recognize the theme to be enabled for the site
  • maintain the current theme in a local variable currTheme and in localStorage to support reloads and re-visits
  • initialize/apply the theme when the page is loaded
  • switch the theme with onClick of a button
  • Keep a <slot/> so that users can control the layout/style of the button that will let them toggle the theme (Headless, aye?)
src/components/ModeSwitcher.svelte
<script lang="ts">
    import { onMount } from 'svelte'

    type ThemeType = 'dark' | 'light'

    const THEME_DARK: ThemeType =  'dark'
    const THEME_LIGHT: ThemeType =  'light'

    // Current theme
    let currTheme: ThemeType = THEME_DARK

    // Toggle the default theme
    function toggleTheme() {
        window.document.body.classList.toggle(THEME_DARK)
        currTheme = localStorage.getItem('theme') === THEME_DARK ? THEME_LIGHT : THEME_DARK
        localStorage.setItem('theme', currTheme)
    }

    // Apply the theme on load
    onMount(() => {
        if (localStorage.getItem('theme') === THEME_DARK || (!('theme' in localStorage) && window.matchMedia(`(prefers-color-scheme: ${THEME_DARK})`).matches)) {
            window.document.body.classList.add(THEME_DARK)
            currTheme = THEME_DARK
        } else {
            window.document.body.classList.remove(THEME_DARK)
            currTheme = THEME_LIGHT
        }
    })
</script>
<button on:click={toggleTheme}>
    <slot theme={currTheme}/>
</button>

Include the component

Open src/components/Header.astro file and use the component created above

src/components/Header.astro
---
    import { SITE } from '$/config'
    import SvgIcon from './SvgIcon.astro'
    import ModeSwitcher from './ModeSwitcher.svelte' // here!
---

place it after the Github list item

    <li>
        <ModeSwitcher client:visible>
            <!-- place your button: <button>switch mode</button> here if not using the <SvgIcon />-->
            <SvgIcon>
                <circle cx="12" cy="12" r="5"></circle>
                <line x1="12" y1="1" x2="12" y2="3"></line>
                <line x1="12" y1="21" x2="12" y2="23"></line>
                <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
                <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
                <line x1="1" y1="12" x2="3" y2="12"></line>
                <line x1="21" y1="12" x2="23" y2="12"></line>
                <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
                <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
            </SvgIcon>
        </ModeSwitcher>
    </li>

You can place just a label, button, or HTML inline-able item, where I've put the SvgIcon component and its content(which renders a sun icon).

See it in Action!

Open the page, and see your Svelte component in action. You'd see the default theme applied. Try pressing the icon, and see if the light theme is traded out for a dark theme.

It didn't work? How come it didn't work? Let's revisit the two things renderers are capable of doing.

  1. render a component to a static string of HTML at build time
  2. rehydrate that HTML to create an interactive component on the client.

The pt. 1 is the default behavior for even your interactive components, which is in line with how Astro works. By that rule, your Svelte component produced just a visual and a non-interactive piece of mark-up. But you needed interactivity, and renderers enable it too, so why it didn't work?

It's because - without the right hints, Astro couldn't recognize that your components need hydration.

So how could you provide that hint?

Let's see...

Controlling Hydration

Astro, let's you code in a framework/library you already know and still eventually produce zero-client-side JS by default coz statically rendered components very hardly need too much interactivity. But for many things other than content experience, interactivity and client-side JS is something you always gonna need to connect with 3rd party services for authentication, persistence, mailers, etc. For such needs, Astro lets you partially hydrate components. So, if your component has interactivity you can use client:when hooks events to initialize your components.

Here's how you can control the hydration.

<MyComponent />``` - will render an HTML-only version of MyComponent (default)
<MyComponent:load /> will render MyComponent on page load
<MyComponent:idle /> will use requestIdleCallback() to render MyComponent as soon as main thread is free
<MyComponent:visible /> will use an IntersectionObserver to render MyComponent when the element enters the viewport
<MyComponent:MediaQuery /> will hydrate when the media query consition matches

For our ModeSwitcher.svelte compoonent all it needs is a client:visible hint

    <li>
        <ModeSwitcher client:visible>
            <SvgIcon>
               <!-- prev code unchanged -->
            </SvgIcon>
        </ModeSwitcher>
    </li>

Now, play with the ModeSwitcher, and the Svelte component should work as expected.

astro-ink -63bfa5eb77d3f30c4db281855b5de7f53dac3a52

feat(4.1): Interactivity and Hydration

So, Where should I draw the line?

Unlike UI framework-based SSG solutions, you don't have the concept of one single framework controlling the client-side experience for routing, data-driven, and interactive components by default. You choose what you want and exactly when you want it. That's one of the core tenets that help Astro ship no-js by default. Your every page is stand-alone, not stitched by a routing solution but relying purely on static linking. But, this is not a limitation. You can still mount a UI framework, or routing solution on individual pages, by hydrating correctly.

For our ModeSwitcher.svelte component, you can see it's working just fine. All the behavior is encapsulated and kicks in on hydration and then later owned by the UI framework of choice itself. But what if you need Astro to not just place/inject static components within a Svelte component? What if you need the SVGIcons to react based on the current internal state of the ModeSwitcher? A sun icon when you're in dark mode, and a moon icon where you're in the light mode to facilitate the next toggleable state? You cannot do it in its current make. Though ModeSwitcher.svelte passes the current theme like

<slot theme={currTheme}/>

you cannot effectively use

<ModeSwitcher client:visible let:theme>

The client:when hint, is not just an opt-in interactivity marker, it's also the boundary beyond which, only a FE framework is expected to operate, and before which it's Astro's all-static realm. If you need the button's state to change, you'll have to increase the scope of interactivity, by creating more interactive components, that can encapsulate the inner interactive children.

It means that we'll have to create a Svelte button, to hold both the sun/moon(icon)'s state dynamically. Let's create a SvgIcon.svelte.

src/components/SvgIcon.svelte
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
    <slot/>
</svg>

same as SvgIcon.astro, but mean for Svelte consumption. You can just duplicate SvgIcon.astro and rename it to SvgIcon.svelte. No change is necessary, as it isn't using anything unique to Svelte only(one of the reasons Svelte is a good match with Astro)

And a Svelte component to encapsulate their toggle-able markup

src/components/ModeSwitcherBtn.svelte
<script lang="ts">
    import ModeSwitcher from './ModeSwitcher.svelte'
    import SvgIcon from './SvgIcon.svelte'
</script>
<ModeSwitcher let:theme>
    <SvgIcon>
        {#if theme === 'dark'}
            <circle cx="12" cy="12" r="5"></circle>
            <line x1="12" y1="1" x2="12" y2="3"></line>
            <line x1="12" y1="21" x2="12" y2="23"></line>
            <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
            <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
            <line x1="1" y1="12" x2="3" y2="12"></line>
            <line x1="21" y1="12" x2="23" y2="12"></line>
            <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
            <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
        {:else}
            <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
        {/if}
    </SvgIcon>
</ModeSwitcher>

with the above changes the full-circle of interactive boundary is complete now. In src/components/Header.astro import ModeSwitcherBtn.svelte instead of `ModeSwitcher.svelte'

---
    import { SITE } from '$/config'
    import SvgIcon from './SvgIcon.astro'
import ModeSwitcherBtn from './ModeSwitcherBtn.svelte' // here, ModeSwitcher out!
---

and replace

<ModeSwitcher client:visible>

with

<ModeSwitcherBtn client:visible />

You can see the ThemeSwitcher work just fine.

astro-ink -8a3e9dea82eb61d95febe5c1fe4e7430ac60b930

feat(4.2): Encpasulate behaviour within component types

Local App State with stores

What we've done thus far can take care of components that are descendants and fall in the same, continuous, Svelte-managed tree. What if we want the state in other places?

Store to the rescue!

You can freely change a writable svelte store, and Svelte components somewhere else can easily subscribe to it and react accordingly.

Let's create a central theme store at src/store/theme.ts

src/store/theme.ts
import { writable } from 'svelte/store'
type ThemeType = 'dark' | 'light'

export const theme = writable<ThemeType>('dark')

and update it in our toggle functions inside ModeSwitcher.svelte

src/components/ModeSwitcher.svelte
<script lang="ts">
    // pre-existing code
    import { theme } from '../store/theme'

    function toggleTheme() {
        // pre-existing code
        // Update Store
        theme.set(currTheme)
    }
    onMount(() => {
        // pre-existing code
        theme.set(currTheme)
    })
</script>
// pre-existing code

To expose the theme more conveniently and independent of Button, let's create a provider component

src/components/ModeSensitive.svelte
<script lang="ts">
    import { theme } from '../store/theme'
</script>
{#if $theme === 'dark'}
    <slot name="dark"/>
{:else}
    <slot name="light"/>
{/if}

Now let's say from the usage point-of-view we want to display the current theme in the site's footer. To serve that purpose, let's create a component that will use the store value.

src/components/ModeLabel.svelte
<script lang="ts">
    import ModeSensitive from './ModeSensitive.svelte'
</script>
<ModeSensitive>
    <span slot="dark">(dark)</span>
    <span slot="light">(light)</span>
</ModeSensitive>

Imports the provider component, and display the currently selected theme. <ModeSensitive/> will render <span slot="dark">(dark)</span> for dark theme, and <span slot="light">(light)</span> for the light theme.

With both the Provider/Consumer components orchestrated to produces and consume, the consumer can be placed and hydrated anywhere independently on the page. For us, it's the footer!

src/components/Footer.astro
---
    import { SITE } from '$/config'
import ModeLabel from './ModeLabel.svelte'
---
<div class="footer">
    <nav class="nav">
        <div>2021  &copy; Copyright notice |  <a href={ SITE.githubUrl } title={`${ SITE.name }'s Github URL'`}>{ SITE.name }</a> theme on <a href="https://astro.build/">Astro</a></div>
<ModeLabel client:load/> theme on <a href="https://astro.build/">Astro</a></div>
</nav> </div> <!-- pre-existing code -->

Check the footer's label adapting to the current theme

astro-ink -806bd42033f301ab870131a2d54f1e8a5352724f

feat(4.3): Common store for svelte

If you want you can further re-factor the ThemeSwitcher to use the above store instead of maintaining a local state

Conclusion

Equipped with the knowledge of how to introduce interactivity in the Astro components - You can now enable more renders for other things, replicate what we've done here with React or Vue, and pour in all sorts of client-side behavior in the Astro site you build.

You now have all the understanding to
Build Astro components
Manage Astro components well
Feed local and remote data to Astro components, and
Power Astro components with all the client-side JS you want.

Have we left something un-covered?. Could you recall something else SSG sites must do?

Yes, we did miss a very integral part. Our approach to page generation has been pretty manual and involved in part-2 and part-3 of this series.

So, how can we generate entire pages(with their routes) dynamically?

Let's discover that and more in the upcoming article.

More Resources

Thank you for being till the end 🙌 . If you enjoyed this article, or learned something new, please take a second to tweet this article or share on LinkedIn so others can discover it. Or add to the discussion on Twitter.

Tweet about this post(by clicking the button above) or like/re-tweet one of the tweets that shares this article, and it will show up here eventually