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
- Define a
Springtype inapp/types/spring.ts - Create
SpringCard.vueinapp/components/with props and a computed value - 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:
- Create a
SpringTypeScript interface inapp/types/spring.ts - Create
app/components/SpringCard.vuethat accepts aspringprop - Display the spring's name, truncated description, location, temperature range, and elevation
- Show a colored badge for the spring type (wild, developed, resort)
- Make the entire card a link to the spring's detail page
Implementation hints:
- In Vue, props are defined with
defineProps. The generic syntaxdefineProps<{ spring: Spring }>()gives you type safety without a separate PropTypes library computed()is Vue's equivalent ofuseMemo, but you don't pass a dependency array. Vue tracks dependencies automatically- Components in
app/components/are auto-imported. No need to importSpringCardwhen 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:
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:
<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:
<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.
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.
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:
<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.tsdefines theSpringinterface with all fieldsapp/components/SpringCard.vuerenders a card with name, description, location, temperature, and type badge- The card links to
/springs/:idusingNuxtLink - You can explain why
computeddoesn't need a dependency array in Vue
Solution
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;
}<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?