---
title: "Components & Reactivity"
description: "Create a reusable SpringCard component with props, computed values, and template bindings. Compare Vue's reactivity model to React's useState and useEffect patterns."
canonical_url: "https://vercel.com/academy/nuxt-on-vercel/components-and-reactivity"
md_url: "https://vercel.com/academy/nuxt-on-vercel/components-and-reactivity.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-05-02T09:12:45.306Z"
content_type: "lesson"
course: "nuxt-on-vercel"
course_title: "Nuxt on Vercel"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Components & Reactivity

# Components & Reactivity

React components are functions. You call them, they return JSX, and if you want state, you reach for `useState`. If you want derived values, you compute them inline or wrap them in `useMemo`. If you want side effects, you use `useEffect`. Three hooks, three concerns, and a mental model built around "when does this re-render?"

Vue components work differently. State is reactive by default. Derived values are `computed` properties that track their own dependencies. Side effects are watchers that trigger when specific values change. You never think about re-renders because Vue handles that at the variable level, not the component level.

Once you stop looking for the `useState` equivalent, the Vue model clicks fast.

## Outcome

Build a `SpringCard` component with typed props, computed values, and conditional styling.

## Fast Track

1. Define a `Spring` type in `app/types/spring.ts`
2. Create `SpringCard.vue` in `app/components/` with props and a computed value
3. Verify the component renders spring data with the correct styling

## Hands-on exercise 1.3

Build the `SpringCard` component that we'll use on the browse page to display each hot spring.

**Requirements:**

1. Create a `Spring` TypeScript interface in `app/types/spring.ts`
2. Create `app/components/SpringCard.vue` that accepts a `spring` prop
3. Display the spring's name, truncated description, location, temperature range, and elevation
4. Show a colored badge for the spring type (wild, developed, resort)
5. Make the entire card a link to the spring's detail page

**Implementation hints:**

- In Vue, props are defined with `defineProps`. The generic syntax `defineProps<{ spring: Spring }>()` gives you type safety without a separate PropTypes library
- `computed()` is Vue's equivalent of `useMemo`, but you don't pass a dependency array. Vue tracks dependencies automatically
- Components in `app/components/` are auto-imported. No need to import `SpringCard` when you use it later
- Template expressions use double curly braces `{{ }}` instead of JSX's single braces `{}`

Let's start with the type. In React, you might define this as a TypeScript interface in a `types.ts` file. Same idea here:

```typescript title="app/types/spring.ts"
export interface Spring {
  id: string;
  name: string;
  description: string;
  location: {
    region: string;
    country: string;
    lat: number;
    lng: number;
  };
  temperature: {
    min: number;
    max: number;
  };
  type: "wild" | "developed" | "resort";
  features: string[];
  elevation: number;
  imageUrl: string;
}
```

Nothing surprising. This is the shape of each hot spring in our JSON data.

Now let's build the component. In React, you'd write something like this:

```tsx
// React version — for comparison only
interface SpringCardProps {
  spring: Spring;
}

function SpringCard({ spring }: SpringCardProps) {
  const temperatureLabel = useMemo(
    () => `${spring.temperature.min}–${spring.temperature.max}°F`,
    [spring.temperature.min, spring.temperature.max]
  );

  return <Link href={`/springs/${spring.id}`}>...</Link>;
}
```

Here's the Vue version:

```vue title="app/components/SpringCard.vue"
<script setup lang="ts">
import type { Spring } from "~/types/spring";

const props = defineProps<{
  spring: Spring;
}>();

const temperatureLabel = computed(() => {
  return `${props.spring.temperature.min}–${props.spring.temperature.max}°F`;
});
</script>
```

A few things to notice. `defineProps` replaces the destructured function parameter. `computed` replaces `useMemo`, but there's no dependency array. Vue tracks that `temperatureLabel` depends on `props.spring.temperature` automatically. When the spring data changes, the computed value updates. You don't need to tell it what to watch.

Now the template. This is where Vue diverges most from React:

```vue title="app/components/SpringCard.vue"
<template>
  <NuxtLink :to="`/springs/${spring.id}`">
    <div>
      <div>
        <h3>
          {{ spring.name }}
        </h3>
        <span>
          {{ spring.type }}
        </span>
      </div>

      <p>
        {{ spring.description.slice(0, 120) }}...
      </p>

      <div>
        <span>{{ spring.location.region }}, {{ spring.location.country }}</span>
        <span>{{ temperatureLabel }}</span>
        <span>{{ spring.elevation.toLocaleString() }} ft</span>
      </div>
    </div>
  </NuxtLink>
</template>
```

The colon prefix (`:to`, `:class`) is Vue's shorthand for dynamic attribute binding. `:to` means "evaluate this as JavaScript." Without the colon, it's a plain string. If you've been writing `href={...}` in JSX, the colon is the Vue equivalent of those curly braces.

You can use both `class` and `:class` on the same element. Vue merges them. The static Tailwind classes stay in `class`, and the dynamic type color comes from `:class`. In React, you'd need a template literal or a library like `clsx` to combine them.

\*\*Note: ref vs computed vs plain\*\*

Quick cheat sheet for React developers: `ref()` = `useState()`, `computed()` = `useMemo()`, `watch()` = `useEffect()` with dependencies. Plain variables are fine for things that never change. You'll use all of these, but `computed` carries most of the weight.

\*\*Warning: Props are read-only\*\*

Don't try to reassign `props.spring`. Vue props are read-only, just like React props. If you need to transform prop data, use `computed`. If you need local mutable state derived from a prop, use `ref` with an initial value.

## Try It

We can't render the component on the browse page yet because we haven't wired up data fetching. But we can verify the component file exists and has no syntax errors.

Check that the dev server shows no errors after creating both files. If you see a warning about unused components, that's fine. Nuxt knows `SpringCard` exists but nothing is using it yet.

To preview the component, you can temporarily hardcode a spring in the browse page:

```vue title="app/pages/springs/index.vue"
<script setup lang="ts">
import type { Spring } from "~/types/spring";

const testSpring: Spring = {
  id: "breitenbush-hot-springs",
  name: "Breitenbush Hot Springs",
  description:
    "Tucked into the Willamette National Forest, Breitenbush has been drawing seekers and soakers since the 1920s. The communal tubs sit above a rushing river.",
  location: { region: "Pacific Northwest", country: "US", lat: 44.78, lng: -121.98 },
  temperature: { min: 98, max: 112 },
  type: "developed",
  features: ["clothing-optional", "forest-setting"],
  elevation: 2200,
  imageUrl: "/images/springs/breitenbush-hot-springs.jpg",
};
</script>

<template>
  <div>
    <h1>
      Browse Hot Springs
    </h1>
    <div>
      <SpringCard :spring="testSpring" />
    </div>
  </div>
</template>
```

You should see a card with "Breitenbush Hot Springs," a "developed" badge in sky blue, the temperature range, and the location. Click it and you'll navigate to `/springs/breitenbush-hot-springs`.

Remove the test code when you're done. We'll wire up real data in Section 2.

## Commit

```bash
git add -A && git commit -m "feat(components): add Spring type and SpringCard component"
```

## Done-When

- [ ] `app/types/spring.ts` defines the `Spring` interface with all fields
- [ ] `app/components/SpringCard.vue` renders a card with name, description, location, temperature, and type badge
- [ ] The card links to `/springs/:id` using `NuxtLink`
- [ ] You can explain why `computed` doesn't need a dependency array in Vue

## Solution

```typescript title="app/types/spring.ts"
export interface Spring {
  id: string;
  name: string;
  description: string;
  location: {
    region: string;
    country: string;
    lat: number;
    lng: number;
  };
  temperature: {
    min: number;
    max: number;
  };
  type: "wild" | "developed" | "resort";
  features: string[];
  elevation: number;
  imageUrl: string;
}
```

```vue title="app/components/SpringCard.vue"
<script setup lang="ts">
import type { Spring } from "~/types/spring";

const props = defineProps<{
  spring: Spring;
}>();

const temperatureLabel = computed(() => {
  return `${props.spring.temperature.min}–${props.spring.temperature.max}°F`;
});
</script>

<template>
  <NuxtLink :to="`/springs/${spring.id}`">
    <div>
      <div>
        <h3>
          {{ spring.name }}
        </h3>
        <span>
          {{ spring.type }}
        </span>
      </div>

      <p>
        {{ spring.description.slice(0, 120) }}...
      </p>

      <div>
        <span>{{ spring.location.region }}, {{ spring.location.country }}</span>
        <span>{{ temperatureLabel }}</span>
        <span>{{ spring.elevation.toLocaleString() }} ft</span>
      </div>
    </div>
  </NuxtLink>
</template>
```


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
