Glass Box Terrarium, explained from the inside

Last updated · Latest source on GitHub ↗

Nuxt & VueLesson

Vue components

A Vue page is built from components, each one file that holds its markup, its code and its styles. The code in a component runs once for every instance of it, each time one is created, and Vue redraws the markup when the data that code works with changes. Code that several components need can be pulled out into a composable.

Know Vue components and composables already? Skip to In Terrarium.

Outside the box · General

The concept

A single-file component is a .vue file with up to three blocks: <template> for the markup, <script setup> for the code, and <style> for the CSS, which scoped limits to this component's elements. A compiler turns it into JavaScript at build time. Code in <script setup> runs once for each instance of the component, when Vue creates it (unlike a plain <script>, which runs once when the file is first imported), and the template can use every name it declares. In the template, a : before an attribute binds it to an expression, and v-for repeats an element once per item. defineProps declares the inputs a parent passes in, typed with TypeScript; it needs no import.

A template redraws when its data changes, because Vue tracks what each render reads. ref(value) holds a value in .value; the template reads it without .value. A computed derives a value from refs and props, keeps it, and works it out again only when one of them changes.

A composable is a function, named useSomething by convention, that calls these APIs for whichever component calls it and returns the state. Lifecycle hooks such as onMounted attach to the component whose setup code is running, so a composable is called from <script setup> or from another composable, never later from a timer or a click handler.

Full referenceVue docs: Single-File Components

Looking inside

In Terrarium

Terrarium's pages and components are single-file components, and each that has code writes it in <script setup lang="ts">. Nuxt renders every page on the server first, and a component's code runs there too; Hydration says what that asks of it. Tinkerfund's funding bar shows the blocks at work:

layers/tinkerfund/app/components/tinkerfund/ProgressBar.vue ↗1–23as of
1<script setup lang="ts">
2const props = withDefaults(defineProps<{ percent: number; segments?: number }>(), { segments: 20 })
3const filled = computed(() => Math.round((Math.min(props.percent, 100) / 100) * props.segments))
4</script>
⋯<!-- … -->
18    <i v-for="i in segments" :key="i" :class="{ on: i <= filled }" :style="{ '--i': i }" />
⋯<!-- … -->
22<style scoped>
23.bar { display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 3px; height: 12px; }

The parent passes percent; segments defaults to 20. filled counts the lit segments and changes only when a prop does. The bar sets the CSS custom property --n to the segment count, and each segment sets --i to its place. The <style scoped> block lays the segments out in --n grid columns, colours the lit ones, and times each lit one's sweep from its --i. Nuxt names the component from its folder, and pages use it as <TinkerfundProgressBar> with no import (Nuxt layers says how).

Shared code goes in composables, in a layer's app/composables/ folder, which Nuxt auto-imports like its components. The Journal's useMaintainerView decides whether to show maintainer-only links, and every session card calls it (Hydration shows its code). Tinkerfund's useTinkerfundClock gives a page the Space's current time and a countdown that moves on unless the Space pins its clock. useSpace, which every page inside a starts with, is in File routing.

Why it's built this way

  • A composable keeps a timer and its cleanup together. A live countdown needs a timer started on mount and stopped on unmount. useTinkerfundClock registers both, so a component that wants the time calls one function and never handles the timer. The cost: it can be called only while a component's setup runs. layers/tinkerfund/app/composables/clock.ts.
  • TypeScript in every component. Vue's docs say a template's expressions get stricter type checking when its component's script is TypeScript. Every Terrarium component with code writes <script setup lang="ts">, so pnpm typecheck checks its template too (Type checking). The cost: each prop's type is written out in defineProps. package.json.
  • Custom properties, not a class per segment. The bar's segment count is a prop, so classes for each segment would fix the count in the CSS. ProgressBar sets --n and --i instead, and one grid rule and one delay formula serve any count. The cost: the CSS reads values only the template sets. layers/tinkerfund/app/components/tinkerfund/ProgressBar.vue.
  • Tinkerfund's composables carry its name. Every Tinkerfund composable starts useTinkerfund, which keeps it clear of other layers' names in the one auto-imported namespace (Auto-imports says why). layers/tinkerfund/app/composables/shop.ts.

What to watch for

  • Register hooks before the first await. In an async composable, Vue loses track of the calling component after an await, and a hook registered there is dropped, with only a warning in development. useTinkerfundClock in layers/tinkerfund/app/composables/clock.ts registers onMounted and onUnmounted first, then awaits. A top-level await in <script setup> itself is safe: the compiler restores the component.
  • A module-level ref is shared by every visitor. A ref created outside any function lives once per server process, so one request's value leaks into the next. State that belongs to a request goes in useState, as the Nuxt docs advise, or in refs made during setup, as the Atlas's provideAlmanac does in layers/atlas/app/composables/almanac.ts. useState carries a value from the server to the browser (Hydration).

Try it in your project

In a Nuxt app, add app/components/Percent.vue:

<script setup lang="ts">
const props = defineProps<{ value: number; max?: number }>()
const percent = computed(() => Math.round((props.value / (props.max ?? 100)) * 100))
</script>

<template><p>{{ percent }}% done</p></template>

Use it in a page with <Percent :value="3" :max="12" />, which renders 25% done with no import. Then give the page a ref: write const n = ref(3) in its script, pass :value="n", and add <button @click="n++">More</button>. Each click redraws the text, and percent is worked out again only because n changed.

Where next

Walkthroughs through this Lesson

Last updated · Latest source on GitHub ↗

Something went wrong

This content couldn’t be loaded. Reloading the page usually fixes it.

Technical details
route: /t/glassbox/guide/nuxt-vue/vue-components