Vercel Logo

Components & Reactivity

React components use useState for state, useMemo for cached derived values, and useEffect for side effects. Their mental model centers on component renders.

Vue tracks reactive values directly. Derived values use computed, and watchers handle effects tied to specific values. Vue records those dependencies without an explicit dependency array.

The comparison helps, but Vue's dependency tracking is the model to learn.

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

Start with the type. As in React, define a TypeScript interface in a types file:

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

This interface matches each hot spring in the 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>

defineProps replaces the destructured React function parameter. computed serves the role of useMemo without a dependency array. Vue tracks the access to props.spring.temperature and updates temperatureLabel when that data changes.

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

A useful translation is ref() to useState(), computed() to useMemo(), and watch() to a dependency-based useEffect(). Use plain variables for values that never change.

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>

Was this helpful?

supported.