Getting started with TiniJS framework

Nhan Lam - Apr 20 - - Dev Community

Good weekend, friends! 😚

In the previous post, I have introduced you to a project that I'm currently working on - the TiniJS Framework, if you haven't read it yet, I invite you to check it out - I've created yet another JavaScript framework.

Today we are going to explore the basic concepts of a TiniJS app, including project structure, dev/build tools and components.

To get started, you can download a starter template, or run npx @tinijs/cli@latest new my-app, or try the example apps on Stackblitz:

  1. Photo Gallery App: https://stackblitz.com/edit/try-tinijs
  2. To Do App: https://stackblitz.com/edit/try-tinijs-todo-app

Project structure

For a quick note about the term Projects in the TiniJS platform. Since the TiniJS platform is designed to be as versatile as possible, which means it is able to work with many favorite tools and other frameworks or no frameworks. Therefore, a TiniJS project could be literally any project as long as it involves one or more TiniJS aspects. For example: a Vue app using Tini UI, a React app using Tini Content, a project using Tini CLI expansion, ... More about interoperable will be discussed along the way with future articles.

For this article, we will focus on projects involving an app built using TiniJS core framework. That's being said, let explore TiniJS apps.

A TiniJS app may have any folder structure as you see fit for what you are comfortable to work with. But as a convention, I recommend you use the below structure for most of the case. At the very basic, an app must have two files:

Item Description
app/index.html The entry point of the single page app, where you define: title, meta tags, includes fonts, init app root, ...
app/app.ts The app-root element is where you create a TiniJS client app, register config, setup router, setup UI, ...

As your app grows, we will add different types of code, there are places for different things inside a TiniJS app, we can organize them into these files and folders:

Item Description
tini.config.ts The main configuration source for various purposes across the TiniJS platform.
app/routes.ts For Tini Router, where you define routing behavior of the app.
app/providers.ts Working with services, utils, ... depend on the pattern, you may choose to provide dependencies at the app level, then lazy load them later and inject to pages and components.
app/assets For static assets such as images, SVG icons, ...
app/public For assets which will be copied as is upon build time and can be accessed from public URLs.
app/types Shared Typescript types.
app/configs Client app configuration files based on environments: development.ts, qa.ts, stage.ts, production.ts, ... when using with the Default Compiler, depend on the target environment, a specific config file will be applied.
app/components Reusable app components implement the TiniComponent class.
app/pages App pages for routing purpose.
app/layouts Layouts for pages.
app/icons Reusable icon components.
app/partials Small re-usable html templates which can be included in components and pages.
app/utils Any type of shareable logic functions, depend on the pattern, you can either import or inject them.
app/services Groups of related utilities, you can either import or inject them.
app/consts Shared constants.
app/classes Constructors which are intended to be used to construct objects.
app/stores Stores for global states management.
app/contexts Consumable contexts for mitigating prop-drilling.

When integrate with other tools and frameworks, the specific integrated code will live in its own folder at the same level as the app folder.

Dev and build tools

The development and build workflow of TiniJS are backed by any of your favorite tools. You can either set them up manually or automatically using Tini CLI and official builders. Some of the tools currently available are: Vite, Parcel and Webpack.

In theory, you can use any other tools (Turbo Pack, Gulp, ...). But I don't have time to try them, it is opened up for community contribution.

Vite (recommended, default)

Homepage: https://vitejs.dev/

Option 1: Via Tini CLI (recommended)

  1. Install: npm i -D @tinijs/cli @tinijs/vite-builder
  2. Add scripts:
    • dev: tini dev
    • build: tini build

Option 2: Or, manually

  1. Install: npm i -D vite
  2. Add scripts:
    • dev: vite app
    • build: vite build app --outDir www

Parcel

Homepage: https://parceljs.org/

Option 1: Via Tini CLI (recommended)

  1. Install: npm i -D @tinijs/cli @tinijs/parcel-builder
  2. Config tini.config.ts, set build.builder to @tinijs/parcel-builder
  3. Add scripts:
    • dev: tini dev
    • build: tini build

Option 2: Or, manually

  1. Install: npm i -D parcel @parcel/config-default
  2. Add scripts:
    • dev: parcel app/index.html --dist-dir www
    • build: parcel build app/index.html --dist-dir www

Additional setup

Either using Tini CLI or setup manually, you need to do these additional setup.

  • Modify package.json
{
  "browserslist": "> 0.5%, last 2 versions, not dead",
  "@parcel/resolver-default": {
    "packageExports": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Webpack

Homepage: https://webpack.js.org/

Option 1: Via Tini CLI (recommended)

  1. Install: npm i -D @tinijs/cli @tinijs/webpack-builder
  2. Config tini.config.ts, set build.builder to @tinijs/webpack-builder
  3. Add scripts:
    • dev: tini dev
    • build: tini build

Option 2: Or, manually

  1. Install: npm i -D webpack webpack-cli webpack-dev-server html-bundler-webpack-plugin ts-loader
  2. Add webpack.config.cjs, please see example.
  3. Add scripts:
    • dev: webpack serve --history-api-fallback --mode development
    • build: webpack build --mode production

Working with Components

Components are basic building blocks of TiniJS apps. They are custom elements which extend the standard HTMLElement - the base class of native HTML elements. Since TiniJS is based on Lit, so it is nice to know how to define a component using LitElement, but it is not required because we will explore the basic concepts together.

Create components

To quickly scaffold a component using Tini CLI, run npx tini generate component <name>, a component file will be created at app/components/<name>.ts.

A TiniJS component looks like this:

import {html, css} from 'lit';
import {Component, TiniComponent} from '@tinijs/core';

@Component()
export class AppXXXComponent extends TiniComponent {
  static readonly defaultTagName = 'app-xxx';

  // Logic here

  protected render() {
    return html`<p>Template here</p>`;
  }

  static styles = css`/* Style here */`;
}
Enter fullscreen mode Exit fullscreen mode

There are 3 main sections:

  • Logic: class properties and methods for defining properties, internal states, events and other logic.
  • Template: HTML template with Lit html template literal syntax.
  • Style: CSS for styling the template.

Using components

To consume components, you must first register them, either globally or locally.

Register components globally at the app level, this is convenient since you only need to do it once per component, but it has the drawback that the initial bundle also includes all the related constructors.

// register components globally in app/app.ts

import {AppXXXComponent} from './components/xxx.js';

@App({
  components: [AppXXXComponent]
})
export class AppRoot extends TiniComponent {}
Enter fullscreen mode Exit fullscreen mode

Components can also be registering locally at layout, app or component level. The benefit is that certain components will come with lazy-load pages instead of app initialization. The drawback is that it is repetitive (I think of auto import in the future, it may help a little).

// register components locally

import {AppXXXComponent} from '../components/xxx.js';

@Component|Page|Layout({
  components: [AppXXXComponent]
})
export class ComponentOrPageOrLayout extends TiniComponent {}
Enter fullscreen mode Exit fullscreen mode

Notice that there is defaultTagName = '...'. It is the default tag name of the component, you can register a component with a different tag name, use this syntax:

// AppFooComponent has the default tag name
static readonly defaultTagName = 'app-foo';

// register a different tag name
{
  components: [
    AppXXXComponent,
    [AppFooComponent, 'bar-baz-qux']
  ]
}
Enter fullscreen mode Exit fullscreen mode

After register, you can use the tag <app-xxx></app-xxx> and <bar-baz-qux></bar-baz-qux> just like they are native HTML tags.

Props and events

Components usually has properties and events for data exchange and interaction.

Properties

Use the decorator @Input() or @property() to define properties.

import {property} from 'lit/decorators/property.js';
import {Input} from '@tinijs/core';

@Component()
export class AppXXXComponent extends TiniComponent {

  // Lit syntax
  @property() prop1?: string;

  // or, TiniJS syntax
  @Input() prop2?: {foo: number};

}
Enter fullscreen mode Exit fullscreen mode

Passing properties to components is similar to set attributes in native HTML elements, string values as in key="value" and non-string as in .key=${varOrValue}.

html`<app-xxx prop1="Lorem ipsum" .prop2=${{ foo: 999 }}></app-xxx>`
Enter fullscreen mode Exit fullscreen mode

Beside define properties, you can also use Contexts as a form of communicating data. Sometime certain values are required by many components in a long-nested chain of components, passing values down the whole chain (aka. prop drilling) would be very annoying. Use contexts to provide and consume such values is more efficient. Please see more detail at https://lit.dev/docs/data/context/.

Events

Use the decorator @Output() to define events.

import {Output, type EventEmitter} from '@tinijs/core';

@Component()
export class AppXXXComponent extends TiniComponent {
  @Output() event1!: EventEmitter<string>;
  @Output() event2!: EventEmitter<{ foo: number }>;

  emitEvent1() {
    this.event1.emit('Lorem ipsum');
  }

  protected render() {
    return html`<button @click=${() => this.event2.emit({ foo: 999 })}></button>`;
  }
}
Enter fullscreen mode Exit fullscreen mode

Event payloads can be accessed via the detail field.

html`
  <app-xxx
    @event1=${({detail: event1Payload}: CustomEvent<string>) => console.log(event1Payload)}
    @event2=${({detail: event2Payload}: CustomEvent<{ foo: number }>) => console.log(event2Payload)}
  ></app-xxx>
`
Enter fullscreen mode Exit fullscreen mode

Handle states

By default, class properties changes won't trigger the UI changes. In order to trigger render() every time a value changes you must implicitly define a state, states can either be local or global.

Local states

The properties defined using @property() or @Input() as we see above are already local states which means changing those properties will trigger render().

To define local states but not in the form of property, use the @state() or @Reactive().

import {state} from 'lit/decorators/state.js';
import {Reactive} from '@tinijs/core';

@Component()
export class AppXXXComponent extends TiniComponent {

  // Lit syntax
  @state() state1?: string;

  // or, TiniJS syntax
  @Reactive() state2: number = 123;

  protected render() {
    return html`
      <div>${this.state1}</div>
      <div>${this.state2}</div>
    `;
  }
}
Enter fullscreen mode Exit fullscreen mode

Global states

States can also be organized in some central places (aka. stores). You can use Tini Store (very simple, ~50 lines) or other state management solutions such as MobX, TinyX, ...

Getting started with Tini Store by install npm i @tinijs/store. Then create a store:

import {createStore} from '@tinijs/store';

export const mainStore = createStore({
  foo: 'bar'
});
Enter fullscreen mode Exit fullscreen mode

After creating a store, you now can access its states, subscribe to state changes and mutate states.

import {Subscribe} from '@tinijs/store';

import {mainStore} from './stores/main.js';

/*
 * Access states
 */

const foo = mainStore.foo;

/*
 * Mutate states
 */

// assign a new value
mainStore.foo = 'bar2';

// or, using the 'commit' method
mainStore.commit('foo', 'bar3');

/*
 * Subscribe to state changes
 */

@Component()
export class AppXXXComponent extends TiniComponent {

  // Use the @Subscribe() decorator
  // this.foo will be updated when mainStore.foo changes it is reactive by default
  @Subscribe(mainStore) foo = mainStore.foo;

  // use a different variable name
  @Subscribe(mainStore, 'foo') xyz = mainStore.foo;

  // to turn of reactive, set the third argument to false
  @Subscribe(mainStore, null, false) foo = mainStore.foo;

  // Or, subscribe and unsubscribe manually
  onInit() {
    this.unsubscribeFoo = mainStore.subscribe('foo', value => {
      // do something with the new value
    });
  }
  // NOTE: remember to unsubscribe when the component is destroyed
  onDestroy() {
    this.unsubscribeFoo();
  }

}
Enter fullscreen mode Exit fullscreen mode

Use Signals

Another method for managing states is using Signals. Signals are an easy way to create shared observable state, state that many elements can use and update when it changes. Please see more detail at https://www.npmjs.com/package/@lit-labs/preact-signals.

Lifecyle hooks

Custom elements created by extending HTMLElement as well as LitElement have their lifecycle hooks for tapping into when needed, please see https://lit.dev/docs/components/lifecycle/ for more detail.

There are some other hooks supported by TiniComponent, includes: OnCreate, OnDestroy, OnChanges, OnFirstRender, OnRenders, OnInit, OnReady, OnChildrenRender, OnChildrenReady. Here is a quick summary of them.

OnCreate

Alias of connectedCallback() without the need of calling super.connectedCallback(). The very beginning of a component, the element has got connected to the DOM (more detail).

import {type OnCreate} from '@tinijs/core';

export class Something extends TiniComponent implements OnCreate {
  onCreate() {}
}
Enter fullscreen mode Exit fullscreen mode

OnDestroy

Alias of disconnectedCallback() without the need of calling super.disconnectedCallback(). The end of an element, got removed from the DOM (more detail).

import {type OnDestroy} from '@tinijs/core';

export class Something extends TiniComponent implements OnDestroy {
  onDestroy() {}
}
Enter fullscreen mode Exit fullscreen mode

OnChanges

Alias of willUpdate() of LitElement. Used to computed values using in the render() (more detail).

import {type OnChanges} from '@tinijs/core';

export class Something extends TiniComponent implements OnChanges {
  onChanges() {}
}
Enter fullscreen mode Exit fullscreen mode

OnFirstRender

Alias of firstUpdated() of LitElement. The render() has run the first time (more detail).

import {type OnFirstRender} from '@tinijs/core';

export class Something extends TiniComponent implements OnFirstRender {
  onFirstRender() {}
}
Enter fullscreen mode Exit fullscreen mode

OnRenders

Alias of updated() of LitElement. Changes has been updated and rendered (more detail).

import {type OnRenders} from '@tinijs/core';

export class Something extends TiniComponent implements OnRenders {
  onRenders() {}
}
Enter fullscreen mode Exit fullscreen mode

OnInit

Can be used in interchangeable with OnCreate. When use with Lazy DI injection, injected dependencies available starting from onInit(), usually used to handle async tasks.

import {type OnInit} from '@tinijs/core';

export class Something extends TiniComponent implements OnInit {
  async onInit() {}
}
Enter fullscreen mode Exit fullscreen mode

OnReady

Similar to OnFirstRender but it only counts after any async tasks in OnInit has been resolved and render (first stateful render).

import {type OnReady} from '@tinijs/core';

export class Something extends TiniComponent implements OnReady {
  async onReady() {}
}
Enter fullscreen mode Exit fullscreen mode

Next topic: Working with pages, layouts, meta management and the Router.

Thank you for spending time with me. If there was anything not working for you, please leave a comment or open an issue or ask for help on Discord, I'm happy to assist.

Wish you all the best and happy coding! 💖

. . . .