Hone logo
Hone
Problems

Building Reusable Logic with Vue Renderless Components

Renderless components in Vue.js allow you to extract complex UI logic into reusable components without dictating their presentation. This promotes a clean separation of concerns, making your codebase more maintainable and your components more flexible. Your task is to create a set of renderless components that manage common functionalities, demonstrating the power of this pattern.

Problem Description

You are tasked with building two distinct renderless components in Vue.js using TypeScript:

  1. useCounter: A renderless component that manages a simple counter state.
  2. useFetch: A renderless component that handles data fetching from a given URL.

These components should expose their state and methods to their parent components via the expose API, allowing the parent to control the rendering and behavior.

Key Requirements:

  • useCounter Component:
    • Should manage a count property, initialized to a provided initialValue.
    • Should expose methods to increment, decrement, and reset the counter.
    • The reset method should accept an optional newValue to set the counter to.
  • useFetch Component:
    • Should accept a url prop.
    • Should manage data (the fetched response), loading (a boolean indicating if fetching is in progress), and error (any error encountered during fetching).
    • Should automatically trigger a fetch when the component is mounted and when the url prop changes.
    • Should expose the fetched data, loading state, and any error to the parent.
    • Should also expose a refetch method to manually re-trigger the fetch.

Expected Behavior:

  • Parent components will use these renderless components via their setup function, utilizing renderless pattern principles.
  • The parent component will then use the exposed state and methods to render its UI and control the logic.

Edge Cases to Consider:

  • useCounter: Resetting the counter without a new value.
  • useFetch: Handling network errors, empty responses, and the case where the url prop is initially null or undefined. Ensure that the loading state is correctly managed throughout the fetch lifecycle.

Examples

Example 1: useCounter Usage

Parent Component (CounterDisplay.vue)

<script setup lang="ts">
import { ref } from 'vue';
import useCounter from './useCounter'; // Assume this is your renderless component

const { count, increment, decrement, reset } = useCounter(0);

const handleReset = () => {
  reset(5); // Reset to 5
};
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
    <button @click="decrement">Decrement</button>
    <button @click="reset">Reset</button>
    <button @click="handleReset">Reset to 5</button>
  </div>
</template>

useCounter.ts (Renderless Component)

import { ref, Ref } from 'vue';

interface UseCounterReturn {
  count: Ref<number>;
  increment: () => void;
  decrement: () => void;
  reset: (newValue?: number) => void;
}

export default function useCounter(initialValue: number = 0): UseCounterReturn {
  const count = ref<number>(initialValue);

  const increment = () => {
    count.value++;
  };

  const decrement = () => {
    count.value--;
  };

  const reset = (newValue?: number) => {
    count.value = newValue !== undefined ? newValue : initialValue;
  };

  return {
    count,
    increment,
    decrement,
    reset,
  };
}

Output (when interacting with buttons):

  • Initial: Count: 0
  • After clicking "Increment" twice: Count: 2
  • After clicking "Decrement": Count: 1
  • After clicking "Reset": Count: 0
  • After clicking "Reset to 5": Count: 5

Example 2: useFetch Usage

Parent Component (UserDataFetcher.vue)

<script setup lang="ts">
import { computed } from 'vue';
import useFetch from './useFetch'; // Assume this is your renderless component

const userId = 1; // Example user ID
const userUrl = computed(() => `https://jsonplaceholder.typicode.com/users/${userId}`);

const { data: user, loading, error, refetch } = useFetch(userUrl.value);
</script>

<template>
  <div>
    <h1>User Data</h1>
    <div v-if="loading">Loading user data...</div>
    <div v-else-if="error">Error loading user data: {{ error.message }}</div>
    <div v-else-if="user">
      <p><strong>Name:</strong> {{ user.name }}</p>
      <p><strong>Email:</strong> {{ user.email }}</p>
      <button @click="refetch">Refetch Data</button>
    </div>
    <div v-else>No user data available.</div>
  </div>
</template>

useFetch.ts (Renderless Component)

import { ref, Ref, watch } from 'vue';

interface User {
  id: number;
  name: string;
  email: string;
  // ... other user properties
}

interface UseFetchReturn<T> {
  data: Ref<T | null>;
  loading: Ref<boolean>;
  error: Ref<Error | null>;
  refetch: () => Promise<void>;
}

export default function useFetch<T = any>(url: string | null): UseFetchReturn<T> {
  const data = ref<T | null>(null);
  const loading = ref<boolean>(false);
  const error = ref<Error | null>(null);

  const fetchData = async () => {
    if (!url) {
      data.value = null;
      return;
    }

    loading.value = true;
    error.value = null;
    data.value = null; // Clear previous data

    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      data.value = await response.json();
    } catch (e: any) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  };

  watch(url, () => {
    fetchData();
  }, { immediate: true }); // Fetch immediately on mount

  const refetch = () => {
    return fetchData();
  };

  return {
    data,
    loading,
    error,
    refetch,
  };
}

Output (assuming successful fetch):

User Data

Name: Leanne Graham
Email: Sincere@april.biz

Example 3: useFetch with Invalid URL

Parent Component (InvalidDataFetcher.vue)

<script setup lang="ts">
import useFetch from './useFetch';

const { data, loading, error } = useFetch('https://jsonplaceholder.typicode.com/nonexistent-resource');
</script>

<template>
  <div>
    <h1>Invalid Data Fetch</h1>
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">An error occurred: {{ error.message }}</div>
    <div v-else-if="data">Data fetched successfully (unexpected).</div>
    <div v-else>No data and no error.</div>
  </div>
</template>

Output:

Invalid Data Fetch

An error occurred: HTTP error! status: 404

Constraints

  • Use Vue 3 Composition API with TypeScript.
  • Components must be implemented as functions that return an object of exposed properties (composition functions).
  • No direct DOM manipulation within the renderless components.
  • The useFetch component must use the native fetch API.
  • The watch and computed reactivity utilities from Vue can be used within your composition functions.

Notes

  • Think about how the parent component will receive and use the values returned by your composition functions. This is the essence of the renderless component pattern.
  • For useFetch, consider how to handle the initial fetch when the component mounts.
  • The refetch function in useFetch should return a Promise to allow the parent component to react to the completion of the refetch if necessary.
  • The provided examples demonstrate how a parent component would "use" these renderless logic containers. You are building the logic containers themselves.
Loading editor...
typescript