Frontend Evolution — Rethinking Frontend - part 3 - introduction to jModel

Overview jModel is a prototype library designed for managing client-side models (business logic) in a simple, flexible, and efficient way. It offers built-in tools for dependency injection, state management, and validation. This article explores the core concepts of jModel and its role in building modern web applications. Key Features Minimalist API – Focused on simplicity and ease of use Own Dependency Injection (DI) – Supports lazy initialization, allowing dependencies to be registered only when first used Validation Tools – Built-in utilities to handle data validation (at the model level) State Management – Reactive model management with efficient updates Performance Optimization – Tree-shakable, ensuring only used code is included in the final bundle Framework Agnostic – Can be integrated into different frontend frameworks/libraries through adapters Cross-Platform Compatibility – Works on both browsers and Node.js without relying on browser-specific APIs Data Management – Enables switching between various storage and caching solutions depending on the platform Model Definition Similar to writing traditional services for business logic, the first step is to define a model object. A typical setup includes a token for dependency injection, lifecycle option, providers, and a model function factory. export const userSource = { [TOKEN]: Symbol('USER_SOURCE'), [LIFETIME]: Lifetime.scoped, [PROVIDERS]: { [USER_REPOSITORY]: userRepository, [USER_STORE]: userStore, [USER_MAP]: () => mapUserToString, }, [FACTORY]: userSourceFactory, }; Key elements TOKEN: A unique identifier used for dependency injection LIFETIME: Determines how long the model is retained in the dependency injection container (e.g., scoped, singleton). PROVIDERS: Specifies dependencies, which can be other models or standalone functions like mapUserToString. It uses TOKENS as keys, allowing a single token (e.g., USER_REPOSITORY) to have different implementations across models. FACTORY: Specifies how the model is created. userRepository export const USER_REPOSITORY = Token('USER_REPOSITORY'); export const userRepository = { [TOKEN]: USER_REPOSITORY, [LIFETIME]: Lifetime.scoped, [FACTORY]: () => ({ get: userGet, getUserNames: userNamesGetAll }), }; userSourceFactory export const userSourceFactory = ({ inject }: Context) => ({ setRandomUsername, refs: inject(USER_STORE).refs, }); The model factory function provides access to the execution context, allowing to inject dependencies. Also all methods defined within it, such as setRandomUsername, will execute within the model's context. Execution Context Execution context (Context) has two methods: inject - it allow to inject dependencies available in the same scope or singletones execute - it allow to execute different method in the same context setRandomUsername export async function setRandomUsername(this: Context): Promise { const store = this.inject(USER_STORE); const randomUsername = await this.execute(getRandomUsername) store.setUserName(randomUsername); } export async function getRandomUsername(this: Context): Promise { const repository = this.inject(USER_REPOSITORY); const mapUserDataToString = this.inject(USER_MAP); const userNames = await this.repository.userNamesGetAll(); return mapUserDataToString(userNames[0]); } In the functions above, dependencies are injected from model providers such as USER_STORE, USER_REPOSITORY, and USER_MAP. The execute function is also used to call getRandomUsername within the same context as setRandomUsername Reactive State Management jModel supports reactive state management which can be created by calling createReactiveModel function. userStoreFactory function userStoreFactory() { const model = createReactiveModel({ name: '', age: null }); const name = model.getRef((schema) => schema.name); return { setUserName, refs: { name }, }; } The getRef method allows to select fields that need to be observed for changes. Integration with UI Frameworks jModel can be easily integrated with UI frameworks like Angular by using ngContextBuilder adapter. @Injectable() export class UserComponentContext extends ngContextBuilder({ model: userSource, }) {} UserComponentContext usage export class UserComponent { private readonly model = inject(UserComponentContext).model; userName = refToSignal(this.model.refs.name); setRandomUsername(): void { this.model.setRandomUsername(); } } refToSignal is another adapter which transform jModel reactive field into Angular signal Model Validation jModel also includes built-in validation utilities. export function userUpsertStoreFactory() { const userModel = createReactiveModel({ firstName: $field('', { validators: [required] }), lastName: $field('', { validator

Mar 29, 2025 - 20:05
 0
Frontend Evolution — Rethinking Frontend - part 3 - introduction to jModel

Overview

jModel is a prototype library designed for managing client-side models (business logic) in a simple, flexible, and efficient way. It offers built-in tools for dependency injection, state management, and validation. This article explores the core concepts of jModel and its role in building modern web applications.

Key Features

  • Minimalist API – Focused on simplicity and ease of use
  • Own Dependency Injection (DI) – Supports lazy initialization, allowing dependencies to be registered only when first used
  • Validation Tools – Built-in utilities to handle data validation (at the model level)
  • State Management – Reactive model management with efficient updates
  • Performance Optimization – Tree-shakable, ensuring only used code is included in the final bundle
  • Framework Agnostic – Can be integrated into different frontend frameworks/libraries through adapters
  • Cross-Platform Compatibility – Works on both browsers and Node.js without relying on browser-specific APIs
  • Data Management – Enables switching between various storage and caching solutions depending on the platform

Model Definition

Similar to writing traditional services for business logic, the first step is to define a model object. A typical setup includes a token for dependency injection, lifecycle option, providers, and a model function factory.

export const userSource = {
  [TOKEN]: Symbol('USER_SOURCE'),
  [LIFETIME]: Lifetime.scoped,
  [PROVIDERS]: {
    [USER_REPOSITORY]: userRepository,
    [USER_STORE]: userStore,
    [USER_MAP]: () => mapUserToString,
  },
  [FACTORY]: userSourceFactory,
};

Key elements

  • TOKEN: A unique identifier used for dependency injection
  • LIFETIME: Determines how long the model is retained in the dependency injection container (e.g., scoped, singleton).
  • PROVIDERS: Specifies dependencies, which can be other models or standalone functions like mapUserToString. It uses TOKENS as keys, allowing a single token (e.g., USER_REPOSITORY) to have different implementations across models.
  • FACTORY: Specifies how the model is created.

userRepository

export const USER_REPOSITORY = Token<UserRepository>('USER_REPOSITORY');
export const userRepository = {
  [TOKEN]: USER_REPOSITORY,
  [LIFETIME]: Lifetime.scoped,
  [FACTORY]: () => ({
    get: userGet,
    getUserNames: userNamesGetAll
  }),
};

userSourceFactory

export const userSourceFactory = ({ inject }: Context) => ({
  setRandomUsername,
  refs: inject(USER_STORE).refs,
});

The model factory function provides access to the execution context, allowing to inject dependencies. Also all methods defined within it, such as setRandomUsername, will execute within the model's context.

Execution Context

Execution context (Context) has two methods:

  • inject - it allow to inject dependencies available in the same scope or singletones
  • execute - it allow to execute different method in the same context

setRandomUsername

export async function setRandomUsername(this: Context): Promise<void> {
  const store = this.inject(USER_STORE);
  const randomUsername = await this.execute(getRandomUsername)

  store.setUserName(randomUsername);
}

export async function getRandomUsername(this: Context): Promise<string> {
  const repository = this.inject(USER_REPOSITORY);
  const mapUserDataToString = this.inject(USER_MAP);
  const userNames = await this.repository.userNamesGetAll();

  return mapUserDataToString(userNames[0]);
}

In the functions above, dependencies are injected from model providers such as USER_STORE, USER_REPOSITORY, and USER_MAP. The execute function is also used to call getRandomUsername within the same context as setRandomUsername

Reactive State Management

jModel supports reactive state management which can be created by calling createReactiveModel function.

userStoreFactory

function userStoreFactory() {
  const model = createReactiveModel({
    name: '',
    age: null
  });
  const name = model.getRef((schema) => schema.name);

  return {
    setUserName,
    refs: {
      name
    },
  };
}

The getRef method allows to select fields that need to be observed for changes.

Integration with UI Frameworks

jModel can be easily integrated with UI frameworks like Angular by using ngContextBuilder adapter.

@Injectable()
export class UserComponentContext extends ngContextBuilder({
  model: userSource,
}) {}

UserComponentContext usage

export class UserComponent {
  private readonly model = inject(UserComponentContext).model;
  userName = refToSignal(this.model.refs.name);

  setRandomUsername(): void {
    this.model.setRandomUsername();
  }
}

refToSignal is another adapter which transform jModel reactive field into Angular signal

Model Validation

jModel also includes built-in validation utilities.

export function userUpsertStoreFactory() {
  const userModel = createReactiveModel({
    firstName: $field('', { validators: [required] }),
    lastName: $field('', { validators: [required] }),
  });

  return { state: userModel.getRefs() };
}

export const USER_UPSERT_STORE =
  Token<ReturnType<typeof userUpsertStoreFactory>>('USER_UPSERT_STORE');

export const userUpsertStore = {
  [TOKEN]: USER_UPSERT_STORE,
  [LIFETIME]: Lifetime.scoped,
  [FACTORY]: userUpsertStoreFactory,
};

The $field method allow to add validators to model fields.

The getRefs method transforms all fields into observable fields, enabling them to be tracked for changes.

Model usage (with two way data binding)

@Injectable()
export class UserUpsertComponentContext extends ngContextBuilder({
  model: userUpsertSource,
}) {}
export class UserUpsertPageComponent {
  ctx = inject(UserUpsertComponentContext);
  form = this.ctx.model.state;

  // jModel library  provides special utilities such as: 
  // isFirstChange, isValid, disable, isDisabled etc.
  isTouched(ref) {
    return isFirstChange(ref);
  }
}

Model usage in HTML

  
    type="text"
    [(ngModel)]="form.firstName.$value"
    name="firstName"
  />
  Value: {{ form.firstName.$value }}
   *ngIf="isTouched(form.firstName) && form.firstName.$errors">
    Errors: {{ form.firstName.$errors | json }}
  

Every ref field has $value field which allow to get current value or set new one and $errors field which allow to get all validation errors.

This is a brief introduction to the jModel library. Below, you'll find links to the project repository and an example application.

Project link: https://github.com/x-model/jmodel

App example: https://github.com/x-model/jmodel/tree/develop/apps/swapp

Would love to hear your thoughts!