npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

flexdi

v0.6.1

Published

Framework-agnostic dependency injection that adapts to your favorite UI library

Readme

FlexDI

A flexible, efficient, and lightweight dependency injection library for React / React Native / Vue3 / Lit applications.

The library is inspired by the principles and architectural approach of NestJS and Angular, but adapted for frontend applications.

FlexDI allows you to organize a modular architecture with separation of concerns, component lifecycle management, and clear separation of business logic from presentation.

Note: This is the first version of the library. The project is open for contributors, and any help is welcome.

Usage Tips

  1. Code Organization: Group related services into modules.
  2. Naming: Use suffixes for different types of classes (Service, Repository, Presenter).
  3. Singleton modules: Use @Singleton() for modules that should be available with explicit import without creating new instances.
  4. Presenters: Use presenters to separate business logic from UI framework.
  5. Testing: The library makes testing easier by allowing real implementations to be replaced with mocks.

Philosophy and Principles

FlexDI is designed to support SOLID principles and clean architecture:

  • Clear separation of business logic from presentation
  • Separate testing of components
  • High modularity and code reusability
  • Reduction of bugs through strict typing and dependency inversion

The absence of global providers is a conscious design decision, not a limitation. This approach reduces the risk of implicit dependencies and increases code maintainability.

The module graph is meant to be a DAG, not just in practice but by design: a cycle between modules (module A imports module B which imports module A, directly or through a longer chain) means two modules aren't actually separate units of responsibility - they're one module artificially split in two. FlexDI treats this as the architectural smell it is rather than something to accommodate: it's rejected at load time with a Circular module dependency detected: ... error naming the full cycle, instead of being left to silently misbehave. The fix is never a workaround in FlexDI itself - it's extracting what both modules actually need into a third, genuinely shared module that they both import.

Installation

Basic Installation

npm install flexdi reflect-metadata
# or
yarn add flexdi reflect-metadata

React/ReactNative/Vue3 useObservable under hood use RxJs.

Additional installation

npm install rxjs
# or
yarn add rxjs

Lit

Additional installation

npm install lit @lit/context
# or
yarn add lit @lit/context

Project configuration

Reflect Metadata

Add the reflect-metadata import at your application's entry point (before using any decorators):

// index.ts or app.ts or main.ts (your entry file)
import 'reflect-metadata'
// ... rest of your imports and code

TypeScript Configuration

Make sure your tsconfig.json includes:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

Vue3 setup:

import { flexdiPlugin } from 'flexdi/vue3'

createApp(App)
.use(flexdiPlugin) // add flexdi directives
.mount('#app')

ReactNative setup (expo)

npm install babel-plugin-transform-typescript-metadata
# or
yarn add babel-plugin-transform-typescript-metadata
// babel.config.js

module.exports = function (api) {
  api.cache(true)
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      "babel-plugin-transform-typescript-metadata",
      ["@babel/plugin-proposal-decorators", { "legacy": true }],
      ["@babel/plugin-proposal-class-properties", { "loose": true }],
    ]
  }
}
// metro.config.js
const {getDefaultConfig} = require('@expo/metro-config')

const config = getDefaultConfig(__dirname)

config.resolver.unstable_enablePackageExports = true;
config.resolver.sourceExts.unshift("mjs");

module.exports = config

Vite setup

By default, Vite transpiles TypeScript with esbuild, which does not emit design:paramtypes metadata — so the optional @Inject() shorthand described above won't activate. To enable it, swap esbuild's TypeScript/decorators handling for Babel's on .ts/.tsx files:

npm install --save-dev vite-plugin-babel babel-plugin-transform-typescript-metadata @babel/plugin-proposal-decorators @babel/plugin-transform-class-properties @babel/preset-typescript
// vite.config.ts
import babel from 'vite-plugin-babel'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    // ...your other plugins (e.g. @vitejs/plugin-react)
    babel({
      include: /\.tsx?$/,
      babelConfig: {
        presets: ['@babel/preset-typescript'],
        plugins: [
          // must come BEFORE the decorators plugin, or no metadata is emitted
          'babel-plugin-transform-typescript-metadata',
          ['@babel/plugin-proposal-decorators', {legacy: true}],
          ['@babel/plugin-transform-class-properties', {loose: true}],
        ],
      },
    }),
  ],
})

Without this setup, FlexDI still works exactly as before — you just need an explicit @Inject() on every constructor parameter.

Core Concepts

FlexDI is built on the following concepts:

  • Modules - core building blocks of the application that encapsulate logic and services
  • Providers - objects that describe how to create and provide dependencies
  • Dependency Injection - automatic provision of dependencies to components
  • Scopes - managing object lifetimes (Singleton, Transient)

Working Example

React A full-featured React project built using FlexDI can be found in the repository:

https://github.com/AndreyShashlovDev/scalpel-frontend

React Native Sample project

https://github.com/AndreyShashlovDev/flexdi-rn

Vue3 Sample project

https://github.com/AndreyShashlovDev/flexdi-vue3

Usage

Defining a Module

import { Module } from 'flexdi'
import { AuthService } from './services/auth.service'
import { AuthServiceImpl } from './services/auth.service.impl'
import { UserService } from './services/user.service'
import { UserServiceImpl } from './services/user.service.impl'

@Module({
  providers: [
    { provide: AuthService, useClass: AuthServiceImpl },
    { provide: UserService, useClass: UserServiceImpl }
  ],
  exports: [AuthService, UserService]
})
export class AppModule {}

Defining a Service with Dependency Injection

import { Inject, Injectable } from 'flexdi'
import { UserRepository } from '../repositories/user.repository'

@Injectable()
export class UserServiceImpl {
  constructor(
    @Inject(UserRepository) private readonly userRepository: UserRepository
  ) {}

  async getUsers() {
    return this.userRepository.getAll()
  }
}

Different modules for React/ React Native / Vue3 / Lit

// react project
import { RootModuleLoader } from 'flexdi/react'

// react native project
import { RootModuleLoader } from 'flexdi/react-native'

// vue3 project
import { RootModuleLoader } from 'flexdi/vue3'

// lit project - registers <flexdi-root-module-loader> etc. as a side effect of the import
import 'flexdi/lit'

Detailed Guide

Decorators

@Module

Defines a class as a module that can contain providers and import other modules.

Parameters:

  • providers - array of providers available within the module
  • imports - array of modules that are imported by the current module
  • exports - array of providers that will be available to modules importing the current module
@Module({
  imports: [CommonModule, AuthModule],
  providers: [
    { provide: UserService, useClass: UserServiceImpl },
    { provide: 'API_URL', useValue: 'https://api.example.com' }
  ],
  exports: [UserService]
})
export class UserModule {}

Important to note:

  • All exports from the root module become available to all modules that are loaded after it.
  • By default, each service has a Singleton scope and is visible in the current module and in modules that import it.
  • Independent modules do not have access to exports of other modules unless these modules are imported into the current module.

@Injectable

Marks a class as available for dependency injection. Optionally, you can specify the scope.

@Injectable() // Default is Scope.SINGLETON
export class UserServiceImpl {}

@Injectable(Scope.TRANSIENT) // New instance on each request
export class LoggerServiceImpl {}

Scope.TRANSIENT gets a fresh instance every time it's resolved — including through getService, useInject, and usePresenter, not just when it's injected as a dependency of another provider. The only requirement is that the whole dependency chain resolves synchronously (no useFactory returning a Promise anywhere in it) — getService/useInject/usePresenter are synchronous APIs, so an async factory in a TRANSIENT provider's chain throws rather than silently blocking.

@Inject

Specifies the token for dependency injection in the constructor. The token can be:

  • An abstract class (Abstract)
  • A string (string)
  • A symbol (Symbol)
  • A concrete class (Type)
constructor(
  @Inject(UserService) private readonly userService: UserService, // Class
  @Inject('API_URL') private readonly apiUrl: string, // String
  @Inject(Symbol.for('Logger')) private readonly logger: Logger, // Symbol
  @Inject(SomeRepository) private readonly repo: Repository // Abstract class
) {}
@Inject is optional for class-typed parameters

@Inject is only required when the token can't be inferred from the constructor parameter's own TypeScript type — a string/symbol token, or when binding to a different implementation than the declared type. When the parameter type itself is a concrete or abstract class, FlexDI can resolve it automatically from design:paramtypes (the same metadata NestJS/Angular rely on), so @Inject becomes optional:

@Injectable()
export class UserServiceImpl {
  constructor(
    private readonly userRepository: UserRepository // no @Inject needed — resolved from the parameter's own type
  ) {}
}

This only works when your build actually emits design:paramtypes metadata for decorated classes:

  • Works out of the box with tsc (emitDecoratorMetadata: true, as configured above).
  • Requires extra setup with esbuild-based bundlers (Vite, and by extension most modern React/Vue web setups) — esbuild does not implement emitDecoratorMetadata. See Vite setup below.
  • If the metadata isn't emitted, FlexDI simply falls back to requiring an explicit @Inject() for that parameter — fully backward compatible, nothing breaks, you just don't get the shorthand.

Types that erase to Object/String/Number/Boolean/Array/Function at runtime (interfaces, primitives, generics, any) can never be inferred this way and always require an explicit @Inject(token).

@Singleton

Marks a module as a singleton that will be created only once and available to all modules with explicit import.

@Singleton()
@Module({
  providers: [{ provide: SharedService, useClass: SharedServiceImpl }],
  exports: [SharedService]
})
export class SharedModule {}

A module marked as @Singleton() can be created at any time, and from that moment it will be available to everyone as a singleton, but only when explicitly imported into a module.

Provider Types

Class Provider

{
  provide: UserService,
  useClass: UserServiceImpl,
  scope: Scope.SINGLETON // optional
}

Value Provider

{
  provide: 'API_KEY',
  useValue: 'secret-api-key'
}

Factory Provider (supports async)

{
  provide: 'ApiClient',
  deps: [ConfigService, LoggerService],
    
  useFactory: async (configService, logger) => {
    const config = await configService.getConfig()
    return new ApiClientImpl(config.apiUrl, logger)
  },
}

Token Provider

{
  provide: 'UserServiceAlias',
  useToken: UserService
}

Basic Presenters

Presenters must inherit from BasicPresenter and implement the ready and destroy methods:

export abstract class BasicPresenter<InitArgs> {
  protected args?: InitArgs

  public init(args?: InitArgs): void {
    this.args = args
    this.ready(args)
  }

  public abstract ready(args?: InitArgs): void
  public abstract destroy(): void
}

Example of a presenter with access to args:

export interface UserPresenterArgs {
  userId: string
}

abstract class UserPresenter extends BasicPresenter<UserPresenterArgs> {
  abstract getUsers(): Observable<User[]>
}

@Injectable()
export class UserPresenterImpl extends UserPresenter {
  private users = new BehaviorSubject<User[]>([])

  constructor(
    @Inject(UserService) private readonly userService: UserService
  ) {
    super()
  }

  public ready(args?: UserPresenterArgs): void {
    // You can use arguments from args
    const userId = args?.userId || 'default'

    // Or through this.args
    console.log(`Initializing for user: ${this.args?.userId}`)

    this.loadUsers(userId)
  }

  public destroy(): void {
    // Cleanup resources when component is destroyed
    this.users.complete()
  }

  private async loadUsers(userId: string): Promise<void> {
    const users = await this.userService.getUsersByManager(userId)
    this.users.next(users)
  }

  public getUsers(): Observable<User[]> {
    return this.users.asObservable()
  }
}

Lifecycle Management

Components can implement the OnDisposeInstance interface to perform resource cleanup when a service is unloaded from the DI container:

import { OnDisposeInstance } from 'flexdi'

export class DatabaseServiceImpl implements OnDisposeInstance {
  private connection: Connection

  constructor() {
    this.connection = createConnection()
  }

  // Automatically called when the service is unloaded from the DI container
  onDisposeInstance(): void {
    this.connection.close()
  }
}

The destroy() method in presenters is called when the view part of the component is destroyed.

Working with Asynchronous Dependencies

FlexDI supports asynchronous initialization and the use of Promises:

@Module({
  providers: [
    {
      provide: 'Config',
      useFactory: async () => {
        const response = await fetch('/api/config')
        return await response.json()
      }
    }
  ],
  exports: ['Config']
})
export class ConfigModule {}

Integration

useInject

Hook for injecting dependencies into functional components:

const userService = useInject(UserService)

usePresenter

Hook for working with presenters, automatically manages their lifecycle:

// Parameters are passed to the init(args?: InitArgs) -> ready(args?: InitArgs) method and are available through this.args
const presenter = usePresenter(UserPresenter, { userId: '123' })

useObservable

Hook for subscribing to Observable with automatic unsubscription:

const users = useObservable(presenter.getUsers(), [])

By default, the hook resubscribes whenever the Observable argument itself changes identity - which happens on every render if it's called inline like above, since e.g. Subject#asObservable() returns a new wrapper object each time it's called, not the same reference back. That's harmless for a plain BehaviorSubject-backed stream (resubscribing just redelivers the current value, and React skips the extra render), but it does mean real work re-runs on every render for an observable chain that does something on subscribe (an HTTP call behind switchMap, a tap, opening a connection).

If that matters, pass an explicit third deps array (same idea as useEffect's) so it only resubscribes when those actually change:

const users = useObservable(presenter.getUsers(), [], [presenter])

React (basic usage same for ReactNative)

Setting Up the Root Module

Applications should always start with a root module:

import { RootModuleLoader, ErrorBoundary } from 'flexdi/react'
import { AppModule } from './modules/app.module'
import { App } from './App'

const root = createRoot(document.getElementById('root'))
root.render(
  <RootModuleLoader
    module={AppModule}
    ErrorBoundary={ErrorBoundary} // is optional (or custom)
    LoadingComponent={LoadingSpinner}
    ErrorComponent={ErrorView}
    enableStrictMode={false} // true ONLY if <StrictMode> is used and you are in dev mode
  >
    <App />
  </RootModuleLoader>
)

Vue3

Setting Up the Root Module

Applications should always start with a root module:

<script setup lang="ts">
import { RootModuleLoader } from 'flexdi/vue3'
import ErrorComponent from './common/app-ui/ErrorComponent.vue'
import LoadingComponent from './common/app-ui/LoadingComponent.vue'
import { RootModule } from './RootModule.ts'

const rootModule = RootModule
</script>

<template>
  <RootModuleLoader
      :module="rootModule"
      :loading-component="LoadingComponent"
      :error-component="ErrorComponent"
  >
    <router-view></router-view>
  </RootModuleLoader>
</template>

Using Dependencies in React / React Native Components

import { usePresenter, useInject, useObservable } from 'flexdi/react'
import { UserService } from './services/user.service'
import { UserPresenter } from './presenters/user.presenter'

export const UserList = () => {
  // Using presenter with automatic initialization and cleanup
  const presenter = usePresenter(UserPresenter)
  const users = useObservable(presenter.getUsers(), [])
  
  // Using service injection
  const userService = useInject(UserService)
  
  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

Using Dependencies in Vue3 Components

<script setup lang="ts">
import { useInject, useObservable, usePresenter } from 'flexdi/vue3'
import { ServiceA } from '../../../common/service/ServiceA.ts'
import { HomePagePresenter } from '../domain/HomePagePresenter.ts'

const presenter = usePresenter(HomePagePresenter, {userId: 123})
const user = useObservable(presenter.getUser(), null)
const error = useObservable(presenter.error(), null)
const isLoading = useObservable(presenter.isLoading(), true)

const serviceA = useInject(ServiceA)

function loadUser() {
  presenter.onUserLoadClick()
}

function callServiceA() {
  serviceA.doSomething()
}
</script>

<template>
  <div>
    <h1>User home page</h1>
    <div v-if="isLoading">Loading...</div>
    <div v-else-if="error">Error is: {{ error }}</div>
    <div v-else-if="user">
      <h2>{{ user.name }}</h2>
      <p>Email: {{ user.email }}</p>
      <button @click="loadUser()">Load user 123</button>
      <button @click="callServiceA()">Call service A</button>
    </div>
  </div>
</template>

Lit

Lit support uses @lit/context for module propagation and Reactive Controllers instead of hooks/composables

  • controllers are meant to be created once in your element's constructor, the same place Lit's own docs recommend creating any controller.

Setting Up the Root Module

// main.ts
import 'reflect-metadata'
import 'flexdi/lit' // registers <flexdi-root-module-loader>, <flexdi-module-loader>, <flexdi-module-provider>
import './app-root.element'
import { AppModule } from './app.module'

const loader = document.createElement('flexdi-root-module-loader') as any
loader.module = AppModule

const loading = document.createElement('p')
loading.slot = 'loading'
loading.textContent = 'Loading...'
loader.appendChild(loading)

loader.appendChild(document.createElement('app-root'))

document.body.appendChild(loader)

Using Dependencies in a Lit Component

import { html, LitElement } from 'lit'
import { customElement } from 'lit/decorators.js'
import { InjectController, ObservableController, PresenterController } from 'flexdi/lit'
import { ServiceA } from '../../common/service/ServiceA'
import { HomePagePresenter } from '../domain/HomePagePresenter'

@customElement('home-page')
class HomePageElement extends LitElement {
  // Controllers are created once, in the constructor (implicit here as field initializers) -
  // never inside render(), same as Lit's own guidance for any ReactiveController.
  private readonly presenterCtrl = new PresenterController(this, HomePagePresenter, {userId: 123})
  private readonly userCtrl = new ObservableController(this, () => this.presenterCtrl.value.getUser(), null)
  private readonly serviceACtrl = new InjectController(this, ServiceA)

  protected render() {
    // A component can connect to the DOM before its module finishes loading (there's no
    // virtual-DOM reconciler deciding when to instantiate it) - check `.isReady` first, the same
    // way you'd check a loading flag in the React/Vue3 examples above.
    if (!this.presenterCtrl.isReady || !this.serviceACtrl.isReady) {
      return html`<div>Loading...</div>`
    }

    const user = this.userCtrl.value

    return html`
      <h1>${user.name}</h1>
      <p>Email: ${user.email}</p>
      <button @click=${() => this.presenterCtrl.value.onUserLoadClick()}>Load user 123</button>
      <button @click=${() => this.serviceACtrl.value.doSomething()}>Call service A</button>
    `
  }
}

Note the property-access difference from hooks: usePresenter(...) returns the presenter directly, while PresenterController is the controller object - the presenter itself is presenterCtrl.value.

Root/ModuleLoader for Lit

<flexdi-root-module-loader>/<flexdi-module-loader> load a module and expose loading/error state through named slots, since that's the native way to project conditional content in a web component - there's no prop-based LoadingComponent/ErrorComponent reference to pass:

<flexdi-root-module-loader .module=${AppModule}>
  <div slot="loading">Loading...</div>
  <div slot="error">Something went wrong</div>
  <app-root></app-root>
</flexdi-root-module-loader>

<app-root> (and anything under it) connects to the DOM as soon as the tree it belongs to does - there's no virtual-DOM layer deferring its construction the way there is in React/Vue3, so it can't be held back until the module is ready. Instead, PresenterController/InjectController/ ObservableController each expose .isReady and only resolve once the loader confirms the module actually loaded (they're notified through the same @lit/context value the loader provides, which it only sets after a successful load) - check it in render() before reading .value, as in the example above.

<flexdi-module-provider> is the Lit equivalent of ModuleProvider - for a module that's typically already re-exported by an ancestor module a Loader resolved, with no loading/error state machine of its own. It resolves the same way (context is set once loading is confirmed), so it's safe either way, whether or not the module happens to already be loaded elsewhere:

<flexdi-module-provider .module=${FeatureModule}>
  <feature-root></feature-root>
</flexdi-module-provider>

There's a working, tested example app in examples/lit in this repository.

RootModuleLoader React / ReactNative

Component for loading the root module of the application:

<RootModuleLoader
  module={AppModule}
  ErrorBoundary={ErrorBoundaryView}
  LoadingComponent={LoadingView}
  ErrorComponent={ErrorView}
  enableStrictMode={false} // true ONLY if <StrictMode> is used and you are in dev mode
>
  <App />
</RootModuleLoader>

The enableStrictMode parameter should be set to true ONLY when <StrictMode> is used in the application and you are in development mode. Otherwise, be sure to set it to false, otherwise the presenters will not receive ready/destroy events and will not work correctly.

RootModuleLoader Vue3

Component for loading the root module of the application:

<script setup lang="ts">
import { RootModuleLoader } from 'flexdi/vue3'
import ErrorComponent from './common/app-ui/ErrorComponent.vue'
import LoadingComponent from './common/app-ui/LoadingComponent.vue'
import { RootModule } from './RootModule.ts'

const rootModule = RootModule
</script>

<template>
  <RootModuleLoader
    :module="rootModule"
    :loading-component="LoadingComponent"
    :error-component="ErrorComponent"
  >
    <router-view></router-view>
  </RootModuleLoader>
</template>

ModuleLoader React / React Native

Component for loading a module and its dependencies:

<ModuleLoader
  module={FeatureModule}
  // children?: ReactNode
  // or
  // Component?: ComponentType<any>
  ErrorBoundary={ErrorBoundaryView}
  LoadingComponent={LoadingView}
  ErrorComponent={ErrorView}
>
  {/*children*/}
  <FeatureComponent /> 
</ModuleLoader>

ModuleLoader Vue3

Component for loading the root module of the application:

<script setup lang="ts">
import { ModuleLoader } from 'flexdi/vue3'
import ErrorComponent from './common/app-ui/ErrorComponent.vue'
import LoadingComponent from './common/app-ui/LoadingComponent.vue'
import { FeatureModule } from './FeatureModule.ts'

const featureModule = FeatureModule
</script>

<template>
  <ModuleLoader
    :module="featureModule"
    :loading-component="LoadingComponent"
    :error-component="ErrorComponent"
  >
    <router-view></router-view>
  </ModuleLoader>
</template>

createModuleRoute React

Function for creating a React Router route with module support and lazy loading of components:

import { lazy } from 'react'
import { createModuleRoute } from 'flexdi/react'

// Lazy loading of component
const UserPage = lazy(() => import('./pages/UserPage'))

const route = createModuleRoute({
  path: '/users',
  module: UserPageModule,
  Component: UserPage, // Lazily loaded component
  ErrorBoundary: ErrorBoundary,
  LoadingComponent: LoadingView,
  ErrorComponent: ErrorView
})

Example usage with multiple routes:

import { lazy } from 'react'
import { createBrowserRouter } from 'react-router-dom'

const HomePage = lazy(() => import('./pages/HomePage'))
const UserPage = lazy(() => import('./pages/UserPage'))

const createAppRoute = (
  {
    path,
    feature,
    module
  }: { path: string, feature: LazyExoticComponent<ComponentType<unknown>>, module: ModuleType }
) => createModuleRoute({
  path,
  module: module,
  Component: feature,
  ErrorBoundary: ErrorBoundary,
  LoadingComponent: LoadingView,
  ErrorComponent: ErrorView,
})

const appRoutes = [
  createAppRoute({
    path: '/',
    feature: HomePage,
    module: HomePageModule,
  }),
  createAppRoute({
    path: '/users',
    feature: UserPage,
    module: UserPageModule,
  })
]

const router = createBrowserRouter(appRoutes)

createModuleScreen / createModuleNavigator React Native

Function for creating a React native navigation with module support and lazy loading of components:

import { createStackNavigator } from '@react-navigation/stack'
import { createModuleNavigator, createModuleScreen, ErrorBoundary } from 'flexdi/react-native'
import React from 'react'
import ErrorScreen from '../common/app-ui/ErrorScreen'
import LoadingScreen from '../common/app-ui/LoadingScreen'
import { CounterScreenModule } from '../feature/counter/di/CounterScreenModule'
import CounterScreen from '../feature/counter/presentation/CounterScreen'
import { HomeScreenModule } from '../feature/home/di/HomeScreenModule'
import HomeScreen from '../feature/home/presentation/HomeScreen'
import { ProfileScreenModule } from '../feature/user/di/ProfileScreenModule'
import ProfileScreen from '../feature/user/presentation/ProfileScreen'
import { NavigationScreen } from './NavigationScreen'

const Stack = createStackNavigator()

// external screen creation
const CounterScreenWithModule = createModuleScreen({
  module: CounterScreenModule,
  Component: CounterScreen,
  LoadingComponent: LoadingScreen,
  ErrorComponent: ErrorScreen,
  ErrorBoundary: ErrorBoundary,
  navigationOptions: {
    title: 'Counter'
  }
})

const navigatorConfig = createModuleNavigator({
  type: 'stack',
  screens: [
    {
      name: NavigationScreen.HOME,
      Component: HomeScreen,
      module: HomeScreenModule,
      options: {title: 'Main'}
    },
    {
      name: NavigationScreen.PROFILE,
      Component: ProfileScreen,
      module: ProfileScreenModule,
      options: {title: 'Profile'}
    }
  ],
  defaultScreenOptions: {
    headerStyle: {
      backgroundColor: '#4a90e2',
    },
    headerTintColor: '#fff',
    headerTitleStyle: {
      fontWeight: 'bold',
    },
  },
  LoadingComponent: LoadingScreen,
  ErrorComponent: ErrorScreen,
  // ErrorBoundary:  optional (used by default boundary)
})

const AppNavigator = () => {
  return (
    <Stack.Navigator
      initialRouteName={NavigationScreen.HOME}
      screenOptions={navigatorConfig.navigatorOptions}
    >
      {navigatorConfig.screens.map(screen => (
        <Stack.Screen
          key={screen.name}
          name={screen.name}
          component={screen.component}
          options={screen.component.navigationOptions}
        />
      ))}
      <Stack.Screen
        name={NavigationScreen.COUNTER}
        component={CounterScreenWithModule}
        options={CounterScreenWithModule.navigationOptions}
      />
    </Stack.Navigator>
  )
}

export default AppNavigator

createModuleRoute Vue3

Function for creating a Vue Router route with module support and lazy loading of components:

import { createModuleRoute } from 'flexdi/vue3'
import { HomePageModule } from '../../feature/home/di/HomePageModule.ts'
import ErrorComponent from '../app-ui/ErrorComponent.vue'
import LoadingComponent from '../app-ui/LoadingComponent.vue'

export default [
  createModuleRoute({
    path: '/',
    name: 'home', // optional
    // meta?: Record<string, unknown> -- optional
    module: HomePageModule,
    component: () => import('../../feature/home/presentation/HomePageView.vue'),
    loadingComponent: LoadingComponent,
    errorComponent: ErrorComponent
  }),
]

Usage Examples

Basic Application Example with Abstract Service

import { BasicPresenter } from './BasicPresenter'

export interface User {
  name: string
  role: string
}

// Defining an abstract class and implementation
export abstract class AuthService {
  abstract isAuthenticated(): boolean

  abstract getUserInfo(): User
}

@Injectable()
class AuthServiceImpl extends AuthService {
  isAuthenticated(): boolean {
    return true
  }

  getUserInfo(): User {
    return {name: 'Admin', role: 'admin'}
  }
}

// Defining modules
@Module({
  providers: [
    // Binding abstract class to concrete implementation
    {provide: AuthService, useClass: AuthServiceImpl}
  ],
  exports: [AuthService]
})
class AuthModule {}

abstract class UserPresenter extends BasicPresenter<void> {
  
  abstract isUserAuthenticated(): Observable<boolean>

  abstract getUserInfo(): Observable<User>
}

// Presenter uses abstract class
@Injectable()
class UserPresenterImpl extends UserPresenter {
  private isAuthenticated = new BehaviorSubject<boolean>(false)
  private userInfo = new BehaviorSubject<User>({name: '', role: ''})

  constructor(@Inject(AuthService) private authService: AuthService) {
    super()
  }

  ready() {
    console.log('UserPresenter initialized')
    this.updateUserState()
  }

  destroy() {
    console.log('UserPresenter destroyed')
    this.isAuthenticated.complete()
    this.userInfo.complete()
  }

  private updateUserState() {
    this.isAuthenticated.next(this.authService.isAuthenticated())
    this.userInfo.next(this.authService.getUserInfo())
  }

  isUserAuthenticated(): Observable<boolean> {
    return this.isAuthenticated.asObservable()
  }

  getUserInfo(): Observable<User> {
    return this.userInfo.asObservable()
  }
}

@Module({
  imports: [AuthModule],
  providers: [{provide: UserPresenter, useClass: UserPresenterImpl}],
  exports: [UserPresenter]
})
class UserModule {}

// React component
const App = () => {
  const presenter = usePresenter(UserPresenter)
  const userInfo = useObservable(presenter.getUserInfo(), {name: '', role: ''})
  const isAuthenticated = useObservable(presenter.isUserAuthenticated(), false)

  return (
    <div>
      <h1>Welcome, {userInfo.name}!</h1>
      <p>Role: {userInfo.role}</p>
      <p>Status: {isAuthenticated ? 'Authenticated' : 'Not authenticated'}</p>
    </div>
  )
}

// Application entry point
createRoot(document.getElementById('root')).render(
  <RootModuleLoader
    module={UserModule}
    ErrorBoundary={ErrorBoundaryView}
    LoadingComponent={LoadingSpinnerView}
    ErrorComponent={ErrorViewView}
    enableStrictMode={false} // Only if <StrictMode> is used and you are in dev mode
  >
    <ModuleLoader
      module={AppPageModule}
      // children = {}
      // Component = {}
      ErrorBoundary={ErrorBoundaryView}
      LoadingComponent={LoadingSpinnerView}
      ErrorComponent={ErrorViewView}
    >
      {/* children used */}
      <App />
    </ModuleLoader>
  </RootModuleLoader>
)

API Reference

ModuleManager

ModuleManager is a global service for managing modules. Here are its main public methods:

loadModule<T>(moduleClass: ModuleType, isRootModule: boolean = false): Promise<T>

Loads a module and all its dependencies. If isRootModule is set to true, the module will be loaded as the root module of the application.

// Loading the root module
await moduleManager.loadModule(AppModule, true)

// Loading a regular module
await moduleManager.loadModule(FeatureModule)

getService<T>(moduleClass: ModuleType, token: InjectionToken<unknown>): T

Gets a service instance from a loaded module.

// Getting a service
const authService = moduleManager.getService<AuthService>(AppModule, AuthService)

isModuleLoaded(moduleClass: ModuleType): boolean

Checks if a module is loaded.

if (moduleManager.isModuleLoaded(FeatureModule)) {
  console.log('Module is already loaded')
}

unloadModule(moduleClass: ModuleType): void

Unloads a module and all its unused dependencies.

// Unloading a module
moduleManager.unloadModule(FeatureModule)

isRootModule(moduleClass: ModuleType): boolean

Checks if a module is the root module.

if (moduleManager.isRootModule(AppModule)) {
  console.log('This is the root module')
}

Testing with FlexDI

FlexDI is great for unit testing thanks to its ability to easily replace dependencies with mocks. Here's an example of testing using Vitest:

Mocking Services

import { Inject, Injectable, Module, ModuleManager, ModuleManagerFactory } from 'flexdi'
import { beforeEach, describe, expect, it, vi } from 'vitest'

abstract class DataService {
  abstract getData(): string[]
}

// Mock service
@Injectable()
class MockDataServiceImpl extends DataService {
  getData = vi.fn().mockReturnValue(['test', 'data'])
}

abstract class UserService {
  abstract processData(): string[]
}

// Service being tested
@Injectable()
class UserServiceImpl {
  constructor(@Inject(DataService) private dataService: DataService) {}

  processData() {
    const data = this.dataService.getData()
    return data.map(item => item.toUpperCase())
  }
}

// Test module with mock
@Module({
  providers: [
    {provide: DataService, useClass: MockDataServiceImpl},
    {provide: UserService, useClass: UserServiceImpl}
  ],
  exports: [UserService, DataService]
})
class TestModule {}

describe('UserService', () => {
  let userService: UserService
  let mockDataService: DataService
  let testModuleManager: ModuleManager

  beforeEach(async () => {
    // Create a new ModuleManager instance for complete test isolation
    ModuleManagerFactory.resetInstance()
    testModuleManager = ModuleManagerFactory.getInstance()

    // Load the test module with our isolated ModuleManager
    await testModuleManager.loadModule(TestModule, true)

    // Get services from the test module
    userService = testModuleManager.getService<UserServiceImpl>(TestModule, UserService)
    mockDataService = testModuleManager.getService<DataService>(TestModule, DataService)
  })

  it('should process data correctly', () => {
    // Check mock service call
    const result = userService.processData()
    expect(mockDataService.getData).toHaveBeenCalled()
    expect(result).toEqual(['TEST', 'DATA'])
  })
})

Testing Presenters

import { BasicPresenter, Inject, Injectable, Module, ModuleManager, ModuleManagerFactory } from 'flexdi'
import { BehaviorSubject, firstValueFrom, Observable } from 'rxjs'
import { beforeEach, describe, expect, it, vi } from 'vitest'

interface User {
  name: string
}

abstract class AuthService {
  abstract isAuthenticated(): boolean

  abstract getUser(): User | null
}

// Mock service
@Injectable()
class MockAuthServiceImpl extends AuthService {
  isAuthenticated = vi.fn().mockReturnValue(true)
  getUser = vi.fn().mockReturnValue({id: 1, name: 'Test User'})
}

abstract class UserPresenter extends BasicPresenter<void> {
  abstract getUser(): Observable<User | null>
}

// Presenter to test
@Injectable()
class UserPresenterImpl extends UserPresenter {
  private user = new BehaviorSubject<User | null>(null)

  constructor(@Inject(AuthService) private authService: AuthService) {
    super()
  }

  ready() {
    if (this.authService.isAuthenticated()) {
      this.user.next(this.authService.getUser())
    }
  }

  destroy() {
    this.user.complete()
  }

  getUser(): Observable<User | null> {
    return this.user.asObservable()
  }
}

@Module({
  providers: [
    {provide: AuthService, useClass: MockAuthServiceImpl},
    {provide: UserPresenter, useClass: UserPresenterImpl}
  ],
  exports: [AuthService, UserPresenter]
})
class TestModule {}

describe('UserPresenter', () => {
  let presenter: UserPresenter
  let mockAuthService: AuthService
  let testModuleManager: ModuleManager

  beforeEach(async () => {
    ModuleManagerFactory.resetInstance()
    // Create a new ModuleManager instance for complete test isolation
    testModuleManager = ModuleManagerFactory.getInstance()

    // Load the test module with our isolated ModuleManager
    await testModuleManager.loadModule(TestModule, true)

    // Get services from the test module
    presenter = testModuleManager.getService<UserPresenter>(TestModule, UserPresenter)
    mockAuthService = testModuleManager.getService<MockAuthServiceImpl>(TestModule, AuthService)

    // Manual init call, simulating lifecycle
    presenter.init()
  })

  it('should load user when authenticated', async () => {
    const user = await firstValueFrom(presenter.getUser())

    expect(mockAuthService.isAuthenticated).toHaveBeenCalled()
    expect(mockAuthService.getUser).toHaveBeenCalled()
    expect(user?.name).toBe('Test User')
  })
})

Testing React hooks

import { render, screen, waitFor } from '@testing-library/react'
import React, { useLayoutEffect, useRef } from 'react'
import { BehaviorSubject, Observable, Subject, takeUntil } from 'rxjs'
import { beforeEach, describe, expect, it } from 'vitest'
import {
  BasicPresenter,
  Inject,
  Injectable,
  Module,
  ModuleManager,
  ModuleManagerFactory,
  ModuleProvider,
} from 'flexdi'
import {useInject, useObservable, usePresenter} from 'flexdi/react'
import '@testing-library/jest-dom'

interface User {
  id: number
  name: string
  email: string
}

abstract class UserService {
  abstract getUsers(): Observable<User[]>
  abstract updateUsers(users: User[]): void
}

@Injectable()
class MockUserServiceImpl extends UserService {
  private users = new BehaviorSubject<User[]>([
    {id: 1, name: 'John Doe', email: '[email protected]'},
    {id: 2, name: 'Jane Smith', email: '[email protected]'}
  ])

  getUsers(): Observable<User[]> {
    return this.users.asObservable()
  }

  updateUsers(users: User[]): void {
    this.users.next(users)
  }
}

abstract class UserPresenter extends BasicPresenter<void> {

  abstract getUsers(): Observable<User[]>

  abstract filterUsersByName(query: string): void
}

@Injectable()
class UserPresenterImpl extends UserPresenter {
  private filteredUsers = new BehaviorSubject<User[]>([])

  constructor(@Inject(UserService) private userService: UserService) {
    super()
  }

  ready(): void {
    this.userService.getUsers().subscribe(users => {
      this.filteredUsers.next(users)
    })
  }

  destroy(): void {
    this.filteredUsers.complete()
  }

  getUsers(): Observable<User[]> {
    return this.filteredUsers.asObservable()
  }

  filterUsersByName(query: string): void {
    this.userService.getUsers().subscribe(users => {
      if (!query) {
        this.filteredUsers.next(users)
        return
      }

      const filtered = users.filter(user =>
        user.name.toLowerCase().includes(query.toLowerCase())
      )
      this.filteredUsers.next(filtered)
    })
  }
}

@Module({
  providers: [
    {provide: UserService, useClass: MockUserServiceImpl},
    {provide: UserPresenter, useClass: UserPresenterImpl}
  ],
  exports: [UserService, UserPresenter]
})
class TestModule {}

function UserList() {
  const presenter = usePresenter(UserPresenter)
  const userService = useInject(UserService)
  const users = useObservable(presenter.getUsers(), [])
  const destroySubject = useRef(new Subject<void>())

  const totalUsers = React.useMemo(() => {
    let count = 0
    userService.getUsers()
      .pipe(takeUntil(destroySubject.current))
      .subscribe(users => {
        count = users.length
      })
    return count
  }, [userService, destroySubject.current])

  useLayoutEffect(() => {
    return () => {
      destroySubject.current.next()
      destroySubject.current.complete()
    }
  }, [destroySubject.current])

  return (
    <div>
      <h1>User List</h1>
      <p data-testid='user-count-presenter'>Total users in presenter: {users.length}</p>
      <p data-testid='user-count-service'>Total users in service: {totalUsers}</p>
      {users.length === 0 ? (
        <p data-testid='empty-message'>No users found</p>
      ) : (
        <ul data-testid='user-list'>
          {users.map(user => (
            <li key={user.id} data-testid={`user-${user.id}`}>
              <strong>{user.name}</strong> ({user.email})
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

function TestApp() {
  return (
    <ModuleProvider module={TestModule}>
      <UserList />
    </ModuleProvider>
  )
}

describe('UserList Component with DI', () => {
  let testModuleManager: ModuleManager
  let mockUserService: UserService

  beforeEach(async () => {
    ModuleManagerFactory.resetInstance()
    testModuleManager = ModuleManagerFactory.getInstance()
    await testModuleManager.loadModule(TestModule, true)

    mockUserService = testModuleManager.getService<UserService>(TestModule, UserService)
  })

  it('renders the user list correctly', async () => {
    render(<TestApp />)

    expect(screen.getByText('User List')).toBeInTheDocument()

    const userCountPresenter = screen.getByTestId('user-count-presenter')
    expect(userCountPresenter).toBeInTheDocument()
    expect(userCountPresenter).toHaveTextContent(/Total users in presenter/)

    const userCountService = screen.getByTestId('user-count-service')
    expect(userCountService).toBeInTheDocument()
    expect(userCountService).toHaveTextContent(/Total users in service/)

    await waitFor(() => {
      expect(screen.getByTestId('user-list')).toBeInTheDocument()
    })

    expect(screen.getByTestId('user-1')).toBeInTheDocument()
    expect(screen.getByTestId('user-2')).toBeInTheDocument()

    expect(screen.getByText('John Doe')).toBeInTheDocument()
    expect(screen.getByText(/[email protected]/)).toBeInTheDocument()
  })

  it('shows empty message when no users', async () => {
    mockUserService.updateUsers([])

    render(<TestApp />)

    await waitFor(() => {
      expect(screen.getByTestId('empty-message')).toBeInTheDocument()
    })
    expect(screen.getByText('No users found')).toBeInTheDocument()
  })

  it('updates when user service changes', async () => {
    render(<TestApp />)

    await waitFor(() => {
      expect(screen.getByTestId('user-list')).toBeInTheDocument()
    })
    expect(screen.getByText('John Doe')).toBeInTheDocument()

    const newUsers = [
      {id: 3, name: 'Bob Johnson', email: '[email protected]'}
    ]
    mockUserService.updateUsers(newUsers)

    await waitFor(() => {
      expect(screen.getByText('Bob Johnson')).toBeInTheDocument()
    })
    expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
  })
})

Supporting the Project

If you like FlexDI and find it useful for your project, please support it:

  • ⭐ Star it on GitHub
  • 🍴 Fork it to contribute improvements
  • 📢 Tell your colleagues about the library

Your support helps to develop the project and make it better!