TL;DR
- Evitar prop drilling en Vue 3 no es solo “comodidad”: reduce acoplamiento y mejora mantenibilidad.
- Composables: encapsulan lógica de negocio reutilizable.
- Provide/Inject: elegante forma de manejo de estado.
- Pinia: estado global centralizado.
Evita props drilling en vue 3
Hoy quería compartir unas estrategias que suelo usar para manejar el estado en aplicaciones realizadas en Vue 3 Composition API + TypeScript, para este ejemplo voy a usar las siguientes tecnologías:
- Pinia 3.0.3
- Axios 1.12.0
- Vue 3.5.18
- Vuetify 3.10.0
Voy a usar también esta API pública llamada Mock.shop para ejemplificar el proyecto.
También uso otras tecnologías mas pero ya si quieres echarle un pequeño ojo a la implementación lo vamos a ir viendo en detalle en la creación de la app. También voy a dejar el repositorio desde donde podemos copiar y reproducir los ejemplos al final.
Antes de empezar ¿Qué es el prop Drilling?
Es un patrón en el que los datos (props) se pasan a través de múltiples componentes anidados, incluso aquellos que no los necesitan, para finalmente llegar a un componente hijo que sí los requiere.
Vue 3 tiene varios sabores a la hora de elegir nuestra receta de manejo de estado y de evitar props drilling (o taladrado de props).
La idea es evitar esto, no porque te vaya a romper tu aplicación si no que es como darte un tiro en una pata, a la larga si tu proyecto crece y usas esta estrategia solo te vas a complicar la vida.
Lo que vamos a hacer es ir creando la aplicación paso a paso mientras te voy explicando en detalle cada cosa.
Es una app básica y la idea es enfocarnos en:
- Evitar el props drilling
- Escribir código limpio y escalable.
- Separación de intereses o mejor conocido como separation of concerns(SoC)
Creamos la app paso a paso
Vamos a empezar por los archivos base para nuestro proyecto.
package.json
{
"name": "avoid-prop-drilling-patterns",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src"
},
"dependencies": {
"axios": "^1.12.0",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuetify": "^3.10.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.4.0",
"globals": "^16.4.0",
"typescript-eslint": "^8.43.0",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0"
}
}
.prettierrc.json
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": true,
"singleQuote": true
}
.eslint.config.js
import eslint from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginVue from 'eslint-plugin-vue';
import globals from 'globals';
import typescriptEslint from 'typescript-eslint';
export default typescriptEslint.config(
{ ignores: ['*.d.ts', '**/coverage', '**/dist'] },
{
extends: [
eslint.configs.recommended,
...typescriptEslint.configs.recommended,
...eslintPluginVue.configs['flat/strongly-recommended'],
],
files: ['**/*.{ts,vue}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: globals.browser,
parserOptions: {
parser: typescriptEslint.parser,
},
},
rules: {
},
},
eslintConfigPrettier
);
.vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
tsconfig.json
{
"compilerOptions": {
"lib": ["es2015", "dom"],
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
Estos son los archivos de config, para tener una base sobre la cual escribir nuestro proyecto.
Nuestra estructura de carpetas se va a ver así para este proyecto de ejemplo:
|-- .gitignore
|-- .prettierrc.json
|-- eslint.config.js
|-- index.html
|-- tsconfig.json
|-- package-lock.json
|-- package.json
|-- README.md
|-- vite.config.js
|-- public
|-- favicon.ico
|-- src
|-- App.vue
|-- main.ts
|-- api
|-- ApiClient.ts
|-- composables
|-- useMockShopAPI.ts
|-- keys
|-- StoreKey.ts
|-- pages
|-- StoreView.vue
|-- router
|-- index.ts
|-- stores
|-- app.ts
|-- types
|-- store.ts
|-- components
|-- storeView
|-- CollectionItems.vue
|-- ProductItems.vue
|-- productItems
|-- ProductImage.vue
|-- ProductItem.vue
|-- collectionItems
|-- CollectionItem.vue
Nuestra primera estrategia para evitar props drilling: Composables
Vamos a crear el cliente de API que usaremos y el composable que lo invoca. Acá la primera forma con Vue: los composables están diseñados para ser reutilizables o para encapsular lógica de negocio, en nuestro caso es la primera alternativa que tenemos para no repetir código, para encapsular lógica de negocio y sobre todo para evitar prop drilling en nuestros componentes. documentación de composables.+
src\main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { router } from '@/router';
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import App from './App.vue';
const vuetify = createVuetify({
components,
directives,
});
const pinia = createPinia();
const app = createApp(App);
app.use(router);
app.use(vuetify);
app.use(pinia);
app.mount('#app');
/src/api/ApiClient.ts
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
RawAxiosRequestHeaders,
} from 'axios';
type TrailingSlashMode = 'always' | 'never' | 'preserve';
export interface ApiClientOptions {
baseURL?: string;
withCredentials?: boolean;
timeout?: number;
maxRedirects?: number;
maxContentLength?: number;
defaultHeaders?: Record<string, string>;
csrfToken?: string;
getCSRFToken?: () => string | undefined;
trailingSlash?: TrailingSlashMode;
onForbidden?: (error: AxiosError) => void;
retries?: number;
retryDelayMs?: number;
}
const isBrowser =
typeof window !== 'undefined' && typeof document !== 'undefined';
function ensureTrailingSlash(
url: string | undefined,
mode: TrailingSlashMode
): string | undefined {
if (!url || mode === 'preserve') return url;
const [pathWithHash, query = ''] = url.split('?');
const [path, hash = ''] = pathWithHash.split('#');
const adjusted =
mode === 'always'
? path.endsWith('/')
? path
: `${path}/`
: path.endsWith('/')
? path.slice(0, -1)
: path;
const q = query ? `?${query}` : '';
const h = hash ? `#${hash}` : '';
return `${adjusted}${q}${h}`;
}
function isFormDataLike(val: unknown): val is FormData {
return typeof FormData !== 'undefined' && val instanceof FormData;
}
function sleep(ms: number) {
return new Promise((res) => setTimeout(res, ms));
}
export function createApiClient(options: ApiClientOptions = {}): AxiosInstance {
const {
baseURL = '/api',
withCredentials = false,
timeout = 60000,
maxRedirects = 10,
maxContentLength = 50 * 1000 * 1000, // 50MB
defaultHeaders,
csrfToken,
getCSRFToken,
trailingSlash = 'always',
retries = 0,
retryDelayMs = 300,
} = options;
const instance = axios.create({
baseURL,
withCredentials,
timeout,
maxRedirects,
maxContentLength,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...defaultHeaders,
},
});
// REQUEST interceptor
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (config.url) {
config.url = ensureTrailingSlash(config.url, trailingSlash);
}
// CSRF
const headerName = 'X-CSRFToken';
const token =
csrfToken ??
(getCSRFToken
? getCSRFToken()
: isBrowser
? document
.querySelector<HTMLElement>('#global-csrf')
?.getAttribute('csrf') ?? undefined
: undefined);
if (
token &&
config.headers &&
!(config.headers as RawAxiosRequestHeaders)[headerName]
) {
(config.headers as RawAxiosRequestHeaders)[headerName] = token;
}
// FormData vs JSON
if (isFormDataLike(config.data)) {
if (config.headers) {
delete (config.headers as RawAxiosRequestHeaders)['Content-Type'];
}
} else {
if (
config.headers &&
!(config.headers as RawAxiosRequestHeaders)['Content-Type']
) {
(config.headers as RawAxiosRequestHeaders)['Content-Type'] =
'application/json';
}
}
(
config as InternalAxiosRequestConfig & { __retryCount?: number }
).__retryCount ??= 0;
return config;
});
// RESPONSE interceptor
instance.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const status = error.response?.status;
const cfg = error.config as
| (AxiosRequestConfig & { __retryCount?: number })
| undefined;
if (cfg && retries > 0) {
const networkError = !error.response;
const serverError = status ? status >= 500 && status < 600 : false;
if (networkError || serverError) {
cfg.__retryCount = (cfg.__retryCount ?? 0) + 1;
if (cfg.__retryCount <= retries) {
const delay = retryDelayMs * Math.pow(2, cfg.__retryCount - 1);
await sleep(delay);
return instance.request(cfg);
}
}
}
return Promise.reject(error);
}
);
return instance;
}
// Strongly-typed wrapper
export interface HttpClient {
get<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: AxiosRequestConfig
): Promise<R>;
delete<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: AxiosRequestConfig
): Promise<R>;
head<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: AxiosRequestConfig
): Promise<R>;
post<T = unknown, B = unknown, R = AxiosResponse<T>>(
url: string,
data?: B,
config?: AxiosRequestConfig
): Promise<R>;
put<T = unknown, B = unknown, R = AxiosResponse<T>>(
url: string,
data?: B,
config?: AxiosRequestConfig
): Promise<R>;
patch<T = unknown, B = unknown, R = AxiosResponse<T>>(
url: string,
data?: B,
config?: AxiosRequestConfig
): Promise<R>;
request<T = unknown, R = AxiosResponse<T>>(
config: AxiosRequestConfig
): Promise<R>;
}
export function wrap(instance: AxiosInstance): HttpClient {
return {
get: (url, config) => instance.get(url, config),
delete: (url, config) => instance.delete(url, config),
head: (url, config) => instance.head(url, config),
post: (url, data, config) => instance.post(url, data, config),
put: (url, data, config) => instance.put(url, data, config),
patch: (url, data, config) => instance.patch(url, data, config),
request: (config) => instance.request(config),
};
}
// --- Default singleton ---
const defaultInstance = createApiClient({
baseURL: '/api',
trailingSlash: 'always',
retries: 0,
});
const defaultClient = wrap(defaultInstance);
export default defaultClient;
useMockShopApi.ts
import { ref } from 'vue';
import { useAppStore } from '@/stores/app';
import { createApiClient } from '@/api/ApiClient';
import type { AxiosResponse } from 'axios';
export function useMockShopAPI<T>() {
const appStore = useAppStore();
const apiError = ref<string>();
const apiData = ref<T>();
const apiClient = createApiClient({
baseURL: 'https://mock.shop/api',
});
const setError = (e: unknown) => {
if (
e &&
typeof e === 'object' &&
'message' in e &&
typeof (e as { message?: unknown }).message === 'string'
) {
apiError.value = (e as { message: string }).message;
} else {
apiError.value = 'Unexpected error';
}
};
const fetchCollections = async (): Promise<AxiosResponse<T> | undefined> => {
appStore.loading = true;
try {
const response = await apiClient.get('', {
params: {
query: `
{
collections(first: 10) {
edges {
cursor
node {
id
handle
title
description
image { id url }
}
}
}
}
`.trim(),
},
});
apiData.value = response.data;
return response;
} catch (error) {
setError(error);
return undefined;
} finally {
appStore.loading = false;
}
};
const fetchProducts = async (
collectionID: string
): Promise<AxiosResponse<T> | undefined> => {
appStore.loading = true;
try {
const response = await apiClient.get('/', {
params: {
query: `
{
collection(id: "${collectionID}") {
id
handle
title
description
image { id url }
products(first: 20) {
edges {
node {
id
title
featuredImage { id url }
}
}
}
}
}
`.trim(),
},
});
apiData.value = response.data;
return response;
} catch (error) {
setError(error);
return undefined;
} finally {
appStore.loading = false;
}
};
return {
fetchCollections,
fetchProducts,
apiData,
apiError,
};
}
Ya culminado esto ahora vamos al siguiente paso que son nuestros componentes base.
src\App.vue
<script setup></script>
<template>
<main>
<RouterView />
</main>
</template>
index.html
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
src\router\index.ts
import { createMemoryHistory, createRouter } from 'vue-router'
import StoreView from '@/pages/StoreView.vue'
const routes = [
{ path: '/', component: StoreView },
]
export const router = createRouter({
history: createMemoryHistory(),
routes,
})
PD: Este clientApi.ts es construido con axios, es bastante útil para cualquiera de tus proyectos por si lo necesitas re utilizar.
Segunda estrategia para evitar props drilling: Provide / Inject
Es una de mis opciones preferidas entre algunas vistas, como yo lo veo el provide inject es una buena manera de manejar lógica de mutación de estado, lectura, etc.
El mejor ejemplo visual que tengo es de la misma documentación oficial de Vue 3
En pocas palabras un componente que esta varios niveles por debajo de nuestro componente padre puede acceder a funciones y variables reactivas de una manera.
Vamos a seguir construyendo componentes para que veas como lo uso y donde lo uso.
src\pages\StoreView.vue
<script setup lang="ts">
import { watch, provide, ref, onMounted, computed } from 'vue';
import { StoreKey } from '@/keys/StoreKey';
import { useMockShopAPI } from '@/composables/useMockShopAPI';
import CollectionItems from '@/components/storeView/CollectionItems.vue';
import ProductItems from '@/components/storeView/ProductItems.vue';
import type {
GetProductsResponse,
GetCollectionsResponse,
} from '@/types/store';
const productEdges =
ref<GetProductsResponse['data']['collection']['products']['edges']>();
const collectionEdges =
ref<GetCollectionsResponse['data']['collections']['edges']>();
const currentCollection = ref<string>();
const showProducts = computed(() => (currentCollection.value ? true : false));
const selectCollection = (collectionID: string) => {
currentCollection.value = collectionID;
};
provide(StoreKey, {
collectionEdges,
productEdges,
currentCollection,
selectCollection,
});
watch(currentCollection, async (newValue) => {
if (newValue) {
const { fetchProducts, apiData, apiError } =
useMockShopAPI<GetProductsResponse>();
await fetchProducts(currentCollection.value);
if (apiError.value) {
console.log(apiError.value);
return;
}
productEdges.value = apiData.value.data.collection.products.edges;
}
});
const reset = () => {
productEdges.value = undefined;
collectionEdges.value = undefined;
currentCollection.value = undefined;
};
const handleFetchCollection = async () => {
reset();
const { fetchCollections, apiData, apiError } =
useMockShopAPI<GetCollectionsResponse>();
await fetchCollections();
if (apiError.value) {
console.log(apiError.value);
return;
}
collectionEdges.value = apiData.value.data.collections.edges;
};
onMounted(async () => {
await handleFetchCollection();
});
</script>
<template>
<CollectionItems
v-if="!showProducts"
@refresh="handleFetchCollection"
></CollectionItems>
<ProductItems v-else @refresh="handleFetchCollection"></ProductItems>
</template>
Acá es vital que entendamos esta parte:
provide(StoreKey, {
collectionEdges,
productEdges,
currentCollection,
selectCollection,
});
Uso el provide/inject para lógica de negocio que requiera de maneja o manipulación de estado, las colecciones, los productos, la colección seleccionada actualmente y por último la función que nos permite seleccionar la colección, haciendo uso de esta estrategia no tengo que reescribir código en componentes hijos de nuestra app y me permite tener separación de intereses, no hago llamados API con provide/inject sino que solo leo o actualizo el estado de mi aplicación.
src\keys\StoreKey.ts
import type { Ref } from 'vue';
import type {
GetProductsResponse,
GetCollectionsResponse,
} from '@/types/store';
import type { InjectionKey } from 'vue';
export const StoreKey = Symbol('StoreKey') as InjectionKey<{
productEdges?: Ref<
GetProductsResponse['data']['collection']['products']['edges']
>;
collectionEdges?: Ref<GetCollectionsResponse['data']['collections']['edges']>;
currentCollection?: Ref<string>;
selectCollection?: (collectionID: string) => void;
}>;
Y StoreKey es un symbol que aloja las variables y funciones que vamos a proveer a nuestros componentes hijos, de nuevo mi intención acá es solo lógica de manejo de estado para nuestra aplicación.
Ahora vamos con los componentes hijos.
src\components\storeView\CollectionItems.vue
<script setup lang="ts">
import { inject, ref, computed } from 'vue';
import { StoreKey } from '@/keys/StoreKey';
import { useAppStore } from '@/stores/app';
import CollectionItem from './collectionItems/CollectionItem.vue';
const appStore = useAppStore();
const emit = defineEmits<{
(e: 'refresh'): void;
}>();
const { collectionEdges, selectCollection } = inject(StoreKey, {
collectionEdges: ref([]),
selectCollection: () => {},
});
const collectionCount = computed(() => collectionEdges?.value?.length || 0);
</script>
<template>
<v-container>
<v-row>
<v-col cols="12">
<h2 class="text-h5 mb-4">Showing {{ collectionCount }} collections</h2>
<v-btn
:loading="appStore.isLoading"
:disabled="appStore.isLoading"
@click="emit('refresh')"
>Refresh</v-btn
>
</v-col>
<v-col
v-for="edge in collectionEdges"
:key="edge.node.id"
cols="12"
md="3"
>
<CollectionItem
class="cursor-pointer"
:collection="edge.node"
@click="selectCollection(edge.node.id)"
></CollectionItem>
</v-col>
</v-row>
</v-container>
</template>
src\components\storeView\ProductItems.vue
<script setup lang="ts">
import { ref, inject, computed } from 'vue';
import { StoreKey } from '@/keys/StoreKey';
import { useAppStore } from '@/stores/app';
import ProductItem from '@/components/storeView/productItems/ProductItem.vue';
const appStore = useAppStore();
const emit = defineEmits<{
(e: 'refresh'): void;
}>();
const { productEdges } = inject(StoreKey, {
productEdges: ref([]),
});
const productCount = computed(() => productEdges?.value?.length || 0);
</script>
<template>
<v-container>
<v-row>
<v-col cols="12">
<h2 class="text-h5 mb-4">
There are a total of {{ productCount }} products
</h2>
<v-btn
:loading="appStore.isLoading"
:disabled="appStore.isLoading"
@click="emit('refresh')"
>Go Back</v-btn
>
</v-col>
<v-col v-for="edge in productEdges" :key="edge.node.id" cols="12" md="3">
<ProductItem :product="edge.node"></ProductItem>
</v-col>
</v-row>
</v-container>
</template>
src\components\storeView\collectionItems\CollectionItem.vue
<script setup lang="ts">
import { inject } from 'vue';
import { StoreKey } from '@/keys/StoreKey';
import { useAppStore } from '@/stores/app';
import type { Collection } from '@/types/store';
const appStore = useAppStore();
interface ProductItemProps {
collection: Collection;
}
defineProps<ProductItemProps>();
const { selectCollection } = inject(StoreKey, {
selectCollection: () => {},
});
</script>
<template>
<v-card
@click="selectCollection(collection.id)"
:loading="appStore.isLoading"
>
<v-img
color="surface-variant"
height="200"
cover
:src="collection.image.url"
></v-img>
<v-card-title>{{ collection.title }}</v-card-title>
<v-card-text>{{ collection.description }}</v-card-text>
</v-card>
</template>
src\components\storeView\productItems\ProductItem.vue
<script setup lang="ts">
import { useAppStore } from '@/stores/app';
import ProductImage from './ProductImage.vue';
import type { Product } from '@/types/store';
const appStore = useAppStore();
interface ProductItemProps {
product: Product;
}
defineProps<ProductItemProps>();
</script>
<template>
<v-card :loading="appStore.isLoading">
<ProductImage :url="product.featuredImage.url"></ProductImage>
<v-card-title>{{ product.title }}</v-card-title>
<v-card-text>{{ product.description }}</v-card-text>
</v-card>
</template>
src\components\storeView\productItems\ProductImage.vue
<script setup lang="ts">
interface ProductImageProps {
url: string;
}
defineProps<ProductImageProps>();
</script>
<template>
<v-img color="surface-variant" height="200" :src="url" cover></v-img>
</template>
Tercera estrategia para evitar props drilling: Pinia
Pinia es una conocida librería de manejo de estados globales centralizado, para nuestra app va a ser muy útil, es muy simple el uso que le doy, solo para marcar si nuestra app esta haciendo algún llamado a una API y luego colocar nuestro flag **loading **en true como puedes ver a continuación.
src\stores\app.ts
import { defineStore } from 'pinia'
export const useAppStore = defineStore('app', {
state: () => ({
loading: false,
}),
getters: {
isLoading: (state) => state.loading
}
})
Con tanto archivo puede que algo se nos este pasando entre tanto copiar y pegar, para evitar ese mal rollo te voy a compartir el repositorio, no mas con descargarlo tienes la app funcionando sin necesidad de tanto copia y pega.
Con esto ya tenemos todo lo que vamos a necesitar para que nuestra app este funcionando y en marcha, debería verse así luego de ejecutar un npm run dev deberíamos estar viendo el proyecto funcionando.
Top comments (0)