Vercel Logo

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:

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:

// 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:

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:

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.

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.

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:

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

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

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;
}
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>