6.8 KiB
Dependency Injection
Modrinth uses a small dependency-injection (DI) layer that uses Vue provide and inject.
This layer shares platform capabilities and page state with common UI components.
The createContext Factory
Define all providers with createContext from packages/ui/src/providers/index.ts. This factory comes from the Reka UI pattern.
The factory returns a typed [inject, provide] tuple:
import { createContext } from '@modrinth/ui'
interface MyContext {
someValue: Ref<string>
doSomething: () => void
}
export const [injectMyContext, provideMyContext] = createContext<MyContext>('MyComponent')
- Call
provideMyContext(value)in thesetup()function of a parent component. - Call
injectMyContext()in thesetup()function of a descendant. It throws an error when no provider exists. - Call
injectMyContext(null)to returnnullwhen the context is optional.
When to Use DI
Use DI in these conditions:
- The same interface needs different implementations on the website and the desktop app.
- Deep descendant components need the same page state, and props must pass through three or more levels.
Platform Abstraction
Components in packages/ui can need capabilities that each frontend implements differently:
| Provider | App frontend | Website frontend |
|---|---|---|
| API client | Tauri IPC client | REST fetch client |
| Notifications | ref() state and window control |
useState() for SSR hydration |
| File picker | Native Tauri dialogs | Browser file inputs |
| Tags | Tauri commands | Nuxt server state |
| Page context | Sidebar and advertisement hooks | No sidebar and no advertisements |
Page Context
Use DI to share page data with deep descendants. Examples include the project sidebar, header, and version components.
Create a Provider
1. Define the Interface
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')
Export the provider from packages/ui/src/providers/index.ts.
2. Use an Abstract Class for Complex Logic
Use an abstract class when the provider has complex platform logic:
export abstract class AbstractMyFeatureManager {
abstract items: Ref<Item[]>
abstract addItem(item: Item): Promise<void>
// Put common logic in the base class.
handleError(err: unknown) {
console.error(err)
}
}
export const [injectMyFeature, provideMyFeature] =
createContext<AbstractMyFeatureManager>('MyFeature')
Refer to AbstractWebNotificationManager in packages/ui/src/providers/web-notifications.ts for an example.
Connect Providers
App Frontend
Make 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 the function in apps/app-frontend/src/providers/setup.ts. App.vue calls this setup file from its setup() function.
Website Frontend
Provide the context in apps/frontend/src/app.vue. Use Nuxt useState() when the state needs SSR hydration:
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' })
},
})
Use Providers
Inject the provider in a component in 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
Use props and emits by default. DI adds an indirect layer, so use it only for a specific reason.
- Use props from a parent to its direct child.
- Keep data in one frontend when only that frontend uses it.
- Use props through one or two intermediate levels.
- Use
ref()orreactive()for component state.
Existing Providers
| Provider | File | Purpose |
|---|---|---|
provideModrinthClient |
providers/api-client.ts |
Supplies the API client. |
provideNotificationManager |
providers/web-notifications.ts |
Manages notifications. |
providePageContext |
providers/page-context.ts |
Supplies page configuration. |
provideProjectPageContext |
providers/project-page.ts |
Manages project page state. |
provideServerContext |
providers/server-context.ts |
Manages server hosting state. |
provideUserPageContext |
providers/user-page.ts |
Manages user page state. |
Key Files
packages/ui/src/providers/index.ts: Contains thecreateContextfactory and provider exports.packages/ui/src/providers/*.ts: Contains provider definitions.apps/frontend/src/app.vue: Contains the Nuxt root-provider setup.apps/app-frontend/src/App.vue: Contains the Tauri root-provider setup.apps/app-frontend/src/providers/setup/: Contains the app provider setup functions.