qttn.dev
blog

Vue is the superior framework for Astro

Sep 23, 2024

---
// Wrapper.astro
---

<Layout>
  <WordBar client:visible />
  <!-- How do you handle global states between these two components -->
  <CodeEditor client:visible />
</Layout>

Pre-introduction

Astro is intented to be content-driven static site generator. With “static” in mind, you might think that it is not possible to have interactive or dynamic components, but you actually can!

Astro utilizes the concept of “islands” where you can have different parts of your site to be rendered differently. This means you can have a static part of your site that loads instantly and defers the heavy, interactive UI to be loaded later.

However, the challenge comes when you want to share states between these islands, because each of them is rendered in different contexts. Usually, for a regular React app, you’d wrap the whole app with Context or use a state management library like Redux.

Can we do better?

Introduction

In the making of CONST!, one of the challenges I faced was checking if the paragraph contains the mandated vocabulary, and display the progress of the game.

  1. WordBar needs to display the set of words and these can be swapped out to get a new word.
  2. CodeEditor should know the word list and notify WordBar if a word is found in the paragraph.

For Vue, we have different ways to achieve this.

Using Vue outside of Vue components

I have been working with Vue 3 since 2020, but I never knew we can already create ref and other reactive objects outside of Vue components.

Yes, we can. You can export it from one file and import it in another Vue component.

export const words = ref([]);

This means you can do this in one file:

<script lang="ts" setup>
import { words } from "@/store/words"; // Import from another file
</script>

<template>
  <ul>
    <li
      v-for="(word, idx) in words"
      :key="word.id"
      :class="{ 'badge-primary': word.match }"
      @click="swapOutWord(idx)"
    >
      {{ word.name }}
    </li>
  </ul>
</template>

and do this in another:

<script lang="ts" setup>
import { words } from "@/store/words"; // Import from another file

const code = ref("I ate a cake, and a banana ate me.");
const matchedAll = computed(
  () =>
    wordStore.value.length > 0 && wordStore.value.every((word) => word.match),
);
watch(
  () => code.value,
  () => {
    wordStore.value = wordStore.value.map((word) => ({
      ...word,
      match: code.value.includes(word.name),
    }));
  },
  {
    immediate: true,
  },
);
</script>

<template>
  <textarea
    :class="{
      'bg-green-200': matchedAll,
    }"
    v-model="code"
  ></textarea>
</template>

But hey, how do you initialize the ref with prefetched data?

If you insert as a prop and the initialize it with onMounted, there is a chance that CodeEditor will be rendered before the words is properly initialized. Moreover, to ensure the race condition of CodeEditor’s watch, WordBar needs to be rendered first.

First, I made WordBar render first by setting it as client:load and everything else as client:visible.

---
const data = [
  /* ... */
];
---

<Layout>
  <WordBar client:load data={data} />
  <CodeEditor client:visible />
</Layout>

Next, what I did was slightly illegal but essentially, I just set the value of the ref directly without any hooks.

<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { words } from "@/store/words";

const $props = defineProps<{
  data: string[];
}>();

words.value = $props.data.map((name) => ({ name, match: false }));
</script>

And that’s it! Now, try changing the code, swapping out the words, and see the magic happen.

  • cake
  • sandwich
  • apple
  • banana
  • pizza

What about other frameworks?

  1. React: useState must only be called in a component.
  2. Svelte: While you can create writable stores outside of components, it is not usable anywhere else other than Svelte components.
  3. Angular: I have no idea.
  4. Qwik: useSignal must only be called in $component but this is understandable because Qwik doesn’t “hydrate”
  5. Solid: Yes! You absolutely can!
import { setTest, test } from "./hooks";

export function SolidComponent() {
  return (
    <div>
      <button onClick={increment}>Increment</button>
      <p>{test()}</p>
    </div>
  );
}

0

However, I’m not a fan of having to import both get and set functions in the same file. It might be convenient to do that in Solid, and it also imposes a rule to not accidentally mutate the state directly.

And I can also do that in Vue

<script setup lang="ts">
import { testVue, increment } from "./hooks";
</script>

<template>
  <div class="flex items-center justify-center gap-4">
    <button class="btn" @click="increment" }>Increment</button>
    <p class="grid h-12 w-12 place-content-center rounded bg-base-200">
      {{ testVue }}
    </p>
  </div>
</template>

0

Wait, both numbers are in sync!

If you noticed that both components in Vue and Solid are in sync, 100 points to you! Yes, they are in sync because I made them in sync.

Ihis is how I glued both Solid and Vue together, which I know, looks sacrilegious.

export const [test, setTest] = createSignal(0);
export const testVue = ref(0);
export function increment() {
  testVue.value++;
}
watch(
  () => testVue.value,
  (val) => {
    setTest(val);
  },
);

createEffect is frowned upon in Solid because it will never be disposed. But in this case, Vue doesn’t warn anything about watch. (I’m not sure. It might leak memory as I speak but it doesn’t warn.)

Just like how you can store.subscribe in Svelte stores outside of Svelte components!

Doing it legally.

If you feel that what we did was illegal, you can use createGlobalState from @vueuse/core to create a global state.

import { createGlobalState } from "@vueuse/core";
import { ref } from "vue";

export const useWords = createGlobalState(() => {
  const words = ref([]);
  return { words };
});

Or you can also swap out ref with useLocalStorage from @vueuse/core to persist the state.

Conclusion

Vue is a very flexible framework that allows you to do a lot of things.

And this allows you to do a lot of things that you can’t do in other frameworks, especially when you want to share states between islands in Astro.