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
- 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{}
Start with the type. As in React, define a TypeScript interface in a types file:
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:
<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:
<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.
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?