Files
Modrinth-plus/standards/frontend/DEPENDENCY_INJECTION.md
Calum H. d0c7575a23 feat: move notion docs to standards folder (#5590)
* feat: move notion docs to standards folder

* fix: remove skills mention (automatic now)
2026-03-16 17:30:05 +00:00

6.9 KiB
Raw Permalink Blame History

Dependency Injection

Modrinth uses a lightweight DI layer built on Vue's provide/inject for sharing platform-specific capabilities and page-level state across shared UI components.

The createContext Factory

All providers are defined using createContext from packages/ui/src/providers/index.ts (adapted from Reka UI). It produces a typed [inject, provide] tuple:

import { createContext } from '@modrinth/ui'

interface MyContext {
	someValue: Ref<string>
	doSomething: () => void
}

export const [injectMyContext, provideMyContext] = createContext<MyContext>('MyComponent')
  • provideMyContext(value) — call in a parent component's setup().
  • injectMyContext() — call in any descendant's setup(). Throws if never provided.
  • injectMyContext(null) — returns null instead of throwing (for optional contexts).

When to Use DI

Use DI when:

  • The same interface needs different implementations depending on the platform (web vs desktop app).
  • Deeply nested components need access to shared page-level state without prop drilling through 3+ levels.

Platform Abstraction (Primary Use Case)

packages/ui components need capabilities that each frontend fulfils differently:

Provider App Frontend Website Frontend
API client Tauri IPC client REST fetch client
Notifications ref() state + app window mgmt useState() for SSR hydration
File picker Native Tauri dialogs Browser file inputs
Tags Tauri commands Nuxt server state
Page context sidebar: true, ad window hooks sidebar: false, no ads

Page-Level Context

Sharing data between a page and deeply nested children — e.g. project page data consumed by sidebar, header, and version components.

Creating a New Provider

1. Define the interface in packages/ui/src/providers/

// packages/ui/src/providers/my-feature.ts
import type { Ref } from 'vue'
import { createContext } from '.'

export interface MyFeatureContext {
	items: Ref<Item[]>
	addItem: (item: Item) => Promise<void>
	removeItem: (id: string) => Promise<void>
}

export const [injectMyFeature, provideMyFeature] = createContext<MyFeatureContext>('MyFeature')

Re-export from the barrel file (packages/ui/src/providers/index.ts).

2. For complex platform-specific logic, use an abstract class

export abstract class AbstractMyFeatureManager {
	abstract items: Ref<Item[]>
	abstract addItem(item: Item): Promise<void>

	// Shared logic lives on the base class
	handleError(err: unknown) {
		console.error(err)
	}
}

export const [injectMyFeature, provideMyFeature] =
	createContext<AbstractMyFeatureManager>('MyFeature')

See AbstractWebNotificationManager in packages/ui/src/providers/web-notifications.ts for a real example.

Wiring Up Providers

App Frontend (Tauri)

Create a setup function in apps/app-frontend/src/providers/setup/:

// apps/app-frontend/src/providers/setup/my-feature.ts
import { ref } from 'vue'
import { provideMyFeature } from '@modrinth/ui'

export function setupMyFeatureProvider() {
	const items = ref<Item[]>([])

	provideMyFeature({
		items,
		addItem: async (item) => {
			await invoke('add_item', { item })
			items.value.push(item)
		},
		removeItem: async (id) => {
			await invoke('remove_item', { id })
			items.value = items.value.filter(i => i.id !== id)
		},
	})
}

Register it in apps/app-frontend/src/providers/setup.ts, which is called from App.vue's setup().

Website Frontend (Nuxt)

Provide directly in apps/frontend/src/app.vue, using Nuxt's useState() where SSR hydration is needed:

provideMyFeature({
	items: useState<Item[]>('my-feature-items', () => []),
	addItem: async (item) => {
		await $fetch('/api/items', { method: 'POST', body: item })
	},
	removeItem: async (id) => {
		await $fetch(`/api/items/${id}`, { method: 'DELETE' })
	},
})

Consuming Providers

In any component across packages/ui, apps/frontend, or apps/app-frontend:

<script setup lang="ts">
import { injectMyFeature } from '@modrinth/ui'

const { items, addItem } = injectMyFeature()
</script>

<template>
	<div v-for="item in items" :key="item.id">{{ item.name }}</div>
	<button @click="addItem({ id: '1', name: 'New' })">Add</button>
</template>

When NOT to Use DI

Default to props and emits. DI adds indirection — only use it with a concrete reason.

  • Parent to direct child — use props.
  • Data only exists in one frontend — keep context local to that app, not in packages/ui.
  • Shallow prop drilling (12 levels) — passing through one intermediate is fine.
  • Component-local state — use ref() / reactive() locally.

Existing Providers

Provider File Purpose
provideModrinthClient providers/api-client.ts API client instance
provideNotificationManager providers/web-notifications.ts Notification management
providePageContext providers/page-context.ts Page config (sidebar, ads)
provideProjectPageContext providers/project-page.ts Project page state + mutations
provideServerContext providers/server-context.ts Server hosting state
provideUserPageContext providers/user-page.ts User page state

Key Files

  • packages/ui/src/providers/index.tscreateContext factory + barrel exports
  • packages/ui/src/providers/*.ts — Provider definitions
  • apps/frontend/src/app.vue — Nuxt root provider setup
  • apps/app-frontend/src/App.vue — Tauri root provider setup
  • apps/app-frontend/src/providers/setup/ — App provider setup functions