1.1.2 • Published 5 months ago

nocta v1.1.2

Weekly downloads
-
License
ISC
Repository
github
Last release
5 months ago

Nocta

Nocta is a small and reactive lightweight typescript library that eases the creation of small to complex user interfaces.

  • Nocta is function based.

  • Nocta works with "Nodes".

Table of Contents

Installation

Add this package to your npm project:

npm install nocta

Introduction

Nocta allows you to develop UI by providing 5 types of nodes.

Tag

An HTML Element.

import { Tag } from "nocta";

Tag("div"); // Nocta.Tag<"div">
// When painted: <div></div>

Tag("div", { id: "123" }); // Nocta.Tag<"div">
// When painted: <div id="123"></div>

Tag("button", { id: "123" }, [
  Tag("strong", [Content("I'm")]),
  Content(" the text inside the button!"),
]); // Nocta.Tag<"button">
// When painted: <button id="123"><strong>I'm</strong> the text inside the button!</button>

Tag("button", [Content("This button received no props!")]); // Nocta.Tag<"button">
// When painted: <button>This button received no props!</button>

Content

An HTML text.

import { Content } from "nocta";

Content("I'm the text node"); // Nocta.Content

Fragment

A container of multiple children.

import { Fragment } from "nocta";

Fragment(Tag("div"), Tag("button"), Content("HTML Text")); // Nocta.Fragment
// When painted: <><div></div><button></button>HTML Text</>

Templates and Components

Templates are functions that return a node (or null). Components make use of Templates to provide reactivity.

Defining templates:

import { Tag, Content } from "nocta";

const myTemplateNode: Nocta.Template<Nocta.Tag<"div">> = () => {
  return Tag("div", [Tag("button", [Content("I'm the text in the button")])]);
};
// myTemplateNode is a node Template: a function that returns a node.
// This function must return a Tag "div"

// Defining Templates custom props:
const myTemplateNode: Nocta.Template<
  Nocta.Tag<"div">,
  { customProp: string }
> = function ({customProp}) {
  ...
};

Defining components:

import { Component } from "nocta";
// Passing this template to a Component will create a Component node.
Component(myTemplateNode); // Nocta.Component

// If the node Template function needs props, pass the arguments to Component:
Component(myComponentFunction, {
  customProp: "string",
}); // Nocta.Component<Nocta.AnyValidNode, { customProp: string }>

So, a Template can be seen as the node that will be returned, and a Component is a node that will use the Template to generate it's inner node and will provide an execution context for it's reactivity.

Parent

A root element that holds children. It serves as the entry point for your UI, as well can be used to create portals.

import { Parent } from "nocta";

Parent(document.querySelector("#root")!, Tag("h1")); // Nocta.Parent
// if html body is:
// <body><div id="root"></div></body>
// When painted:
// <body><div id="root"><h1></h1></div></body>

Usage

Nodes are not intended to be stored in variables as they are dinamically changing. Storing them can produce memory leaks or unexpected behaviour. They are intended to be created at runtime and passed directly.

Mainly, when working with nodes, the main entry points are Templates and Components.

const App = () => {
  // Return the nodes you want.
  return Tag("button", [Content("I'm a button")]);
};

renderNodes(document.body!, Component(App)); // Call 'Component' on the 'Template'

// Or

renderNodes(document.body!, App()); // Use the 'Template' itself if it doesn't need an 'execution context'

Creating Components

Components make use of Templates. A function that returns a node (or null) is a Template. Let's define its type:

const myTemplateNode: Nocta.Template<
  Nocta.Tag<"button">, // The type that will be returned
  {
    prop1: string;
    prop2: number;
  } // Props definition
>;

Nocta.Template generic defines a function that returns a node (Tag, Content, Fragment...) or null. In the given example, the function must return Nocta.Tag<"button">. This generic also defines the props the component will receive. Let's define the function:

import { Tag, Content } from "nocta";

const myTemplateNode: Nocta.Template<
  Nocta.Tag<"button">,
  {
    prop1: string;
    prop2: number;
  }
> = ({ prop1, prop2 }) => {
  console.log("Prop 1", prop1, "Prop 2", prop2);
  return Tag("button", [Content("Im a button")]);
};

The Template function is then used to generate a Component node using Component. Components create an execution context for the template to use its reactivity:

import { Component } from "nocta";

Component(myTemplateNode, {
  prop1: "1",
  prop2: 1,
}); // Nocta.Component<Nocta.Tag<"button">, { prop1: number, prop2: string }>

Rendering Nodes

renderNodes is used to paint nodes. This is your app entry point and this must be called once over the node. Once it's painted, it's lifecycle is managed by the node itself.

renderNodes(document.body, Tag("div"));
renderNodes(document.body, Fragment(Tag("div")));
renderNodes(document.body, Content("Text"));
renderNodes(document.body, myTemplateNode());
renderNodes(document.body, Component(myTemplateNode));
renderNodes(document.body, Parent(document.body));

If you're going to render more than one node over the same root HTMLElement, pass them in the same call (they will be rendered in the order they are defined):

renderNodes(document.body, Tag("div"), Tag("button"),...);

renderNodes alaways returns a Parent node.

const parent: Nocta.Parent = renderNodes(
  document.body,
  Component(myTemplateNode)
);

Note: renderNodes will render the nodes in the next event loop (async). Sync code after calling renderNodes will be executed before they are rendered.

Difference between Template and Component

There are some important characteristics that differentiate Templates and Components.

Templates are used as reutilizable pieces. They do not have their own execution context, so the reactivity generated inside of them will be linked to the nearest component execution context.

Components, on the other hand, create an execution context, enabling reactivity (state, memory, effects, context consumption...).

So, we can define Templates as reusable node templates, and Components as the runtime node that uses that template and provides its reactivity.

import { Fragment, Tag, Content, state } from "nocta";

const myOtherTemplateNode: Nocta.Template<Nocta.Tag<"div">> = () => {
  const [message, setMessage] = state<string>("");
  return Tag("div", [Content(message)]);
};
const myTemplateNode: Nocta.Template<
  Nocta.Fragment,
  {
    prop1: string;
    prop2: number;
  }
> = () => {
  const [counter, setCounter] = state<number>(0);
  return Fragment(
    Tag("button", [Content("I'm a button")]),
    myOtherTemplateNode()
  );
};

renderNodes(document.body, Component(myTemplateNode));

In this example, only myTemplateNode is generated as a Component: myOtherTemplateNode's states will be linked to this context. This means, that calling an state update inside of myOtherTemplateNode will perform an update in the execution context (myTemplateNode).

import { Fragment, Tag, Content, state } from "nocta";

const myOtherTemplateNode: Nocta.Template<Nocta.Tag<"div">> = () => {
  const [message, setMessage] = state<string>("");
  return Tag("div", [Content(message)]);
};
const myTemplateNode: Nocta.Template<
  Nocta.Fragment,
  {
    prop1: string;
    prop2: number;
  }
> = () => {
  const [counter, setCounter] = state<number>(0);
  return Fragment(
    Tag("button", [Content("I'm a button")]),
    Component(myOtherTemplateNode)
  );
};

renderNodes(document.body, Component(myTemplateNode));

In this example, we have wrapped myOtherTemplateNode inside a Component, meaning that calling setMessage will only repaint myOtherTemplateNode.

This allows you to choose how components should react to changes.

Important: don't forget that when a Component (or Template) is updated, all it's children will be updated too.

¿What is a execution context?

When a Component tree is generated, a execution context (the Component) is created and is accessible to all the subtree nodes unless another Component is generated. Once the tree is generated, the context is exited.

Nocta provides the reactivity inside of Components by the use of states, effects, memory and contexts that are joined to the the execution context.

First let's define a node Template. Let's make a simple button counter that increases the counter when clicked:

import { Component, Tag, Content, state } from "nocta";

const myTemplateNode: Nocta.Template<Nocta.Tag<"button">> = () => {
  const [buttonCounter, setButtonCounter] = state<number>(0);

  return Tag(
    "button",
    {
      // Event listeners are defined in lower case.
      onclick(v) {
        // State setters can accept a callback
        // that will contain the actual value
        // Actually this is the same than:
        // setButtonCounter(buttonCounter() + 1)
        setButtonCounter((count) => count + 1);
      },
    },
    [Content(`The counter is: ${buttonCounter()}`)]
  );
};

renderNodes(document.body, myTemplateNode());

If we run this without wrapping myTemplateNode inside Component, states or effects will be linked to the nearest execution context (if exists). In this example, we don't have a running context; this will fail with an error: Execution context is null.

Don't forget that creating a new Component node inside a Component will change the running context and children will be linked to this last created context, until is exited.

State

The state allows to define values that exist in the component's life cycle and they perform node re-updates.

States are created with the state function. This returns and array with getter and setter, like React. A component has no state limits.

const myTemplateNode = () => {
  const [userNeedsLogin, setUserNeedsLogin] = state<boolean>(false);
  const [user, setUser] = state<string>();
  ...
  return Tag("button", {
    onclick(v) {
      // Calling a set state will perform a repaint
      setUser("newuser");
    },
  });
};

State lifecycle

Inside a component, accessing to the state after performing a state update will reflect the new value:

const myTemplateNode = () => {
  const [now, setNow] = state<Date>(new Date(0));

  return Tag("button", {
    onclick(v) {
      console.log(now()); // Prints: 1970 date
      setNow(new Date());
      console.log(now()); // Prints: today's date
    },
  });
};

Important: When a component updates its state, the repaint is managed automatically; calling renderNodes manually is not needed and can lead to inconcluences.

Effect

An effect is a callback that gets executed when the node gets painted. This callback can return another one, that will get executed when the element is unpainted. An element can have multiple effects.

An effect is intended to be used when there is async work to do or actions that have to be fired after render.

import { Tag, effect } from "nocta";

const myTemplateNode = () => {
  effect(() => {
    console.log("I will get executed when painted");

    return () => {
      // Callback return is optional
      // Use it to perform a clean up
      // Don't perform state updates inside
      console.log("I will get executed when unpainted");
    };
  });

  effect(() => {
    ...
  });

  return Tag("button", {
    onclick(v) {
      // perform some work...
      setUserData((l) => ({ ...l, token: "90129" }));
    },
  });
};

Note: Effects will be executed in the order they are defined (FIFO).

Handeling effects

Effects, unlike states, can be defined conditionally:

import { Tag, effect } from "nocta";

const myTemplateNode = () => {
  const [counter, setCounter] = state<number>(0);
  // Only run the effect if number is even
  if (counter() % 2 === 0)
    effect(() => {
      ...
    });
  // Alternatively
  effect(() => {
    if (counter() % 2 === 0)
      ...
  });
  return ...
};

Also performing state updates will perform an execution of the effects. You have to handle the execution flow and memory can help with that.

Memory

Memory allows you to store values during the component's lifecycle. Unlike states, changes in memory will not perform a repaint. It returns a Holder<T>:

Memory can be used to control the node's lifecycle logic. For example, it can be used to choose wether an effect should run or not:

import { Tag, memory, effect } from "nocta";

const myButtonEffectWithMemory = () => {
  const loginMemory = memory<{
    loginAttempts: number;
    userRefreshed: boolean;
  }>();

  effect(() => {
    if (loginMemory.holded.loginAttempts > 2) {
      // Do something
      // Don't forget that changing a memory value won't produce a repaint
      // An state update would be neccessary in this case
    }
  });
  effect(() => {
    if (!loginMemory.holded.userRefreshed) {
      loginMemory.holded.userRefreshed = true;
      // Refresh user
      // If an state update runs later
      // Next render effect execution won't
      // get here: userRefreshed is true
    }
  });
  return Tag("button", {
    onclick(v) {
      loginMemory.holded.loginAttempts++;
      // This won't procude a repaint
    },
  });
};

Getting an element's ref

Memory must be used for ref. ref has been included in v1.1.2 and it allows you to store a reference to the HTMLElement:

import { Tag, memory } from "nocta";

const myButtonMemoryRef = () => {
  const buttonRef = memory<HTMLButtonElement>();

  effect(() => {
    if (buttonRef.holded) {
      // buttonRef.holded contains the HTMLButtonElement reference
    }
  });
  return Tag("button", {
    ref: buttonRef,
  });
};

Context

Context ease the sharing of data between Components.

A context is defined by a class that extends ContextLinker and implements the context interface. This class is then used as a Context template.

import { context, ContextLinker } from "nocta";

interface CounterContext {
  counter: number;
  increase(): void;
  decrease(): void;
}

class CounterContextTemplate extends ContextLinker implements CounterContext {
  public counter = 0;
  public increase() {
    this.counter++;
    this.update();
  }
  public decrease() {
    this.counter--;
    this.update();
  }
}

This defines the structure of the context. update is provided by the extension from ContextLinker and it performs an update of all the consumers consuming this context; this lets you decide when components should update.

To be able to access this context from the components, you must create a ContextLinker using contextWrapper:

import { contextWrapper } from "nocta";

const counterContextWrapper = contextWrapper<CounterContext>();

This is where the Context template will be generated and stored.

Before the components consume this context, it has to be populated with provide. This generates the context using the template class and it gets linked to the ContextWrapper.

import { provide } from "nocta";
const App = () => {
  provide(CounterContextTemplate, counterContextWrapper);
  return Component(counterConsumerTemplate);
};

Then a component can use consume:

import { consume } from "nocta";
const counterConsumerTemplate = () => {
  const counterContext = consume(counterContextWrapper);
  return Content(`The counter is ${counterContext.counter}`);
};

As states, context consumers need an execution context. They have to be wrapped inside of a Component. If they're not wrapped, they will be linked to the nearest execution context or throw an error if there is not one. There's no need of an execution context to provide a context.

A context linker can be cleared using clearWrapper. This can be called when a ContextLinker will no longer be used (if it is used later, you will need to call provide again before using it).

ContextWrapper can be defined anywhere. You can use them even inside of templates (you must handle the context destruction):

import {
  provide,
  consume,
  contextWrapper,
  Content,
  Component,
  renderNodes,
} from "nocta";

const counterConsumerTemplate: Nocta.Template<
  Nocta.Tag<"div">,
  { counterLinker: Nocta.ContextLinker<CounterContext> }
> = () => {
  const counterContext = consume(counterContextWrapper);
  return Tag("div", [
    Tag(
      "button",
      {
        onclick() {
          counterContext.increase();
        },
      },
      [Content("Increase")]
    ),
    Tag(
      "button",
      {
        onclick() {
          counterContext.decrease();
        },
      },
      [Content("Decrease")]
    ),
    Content(`The counter is ${counterContext.counter}`),
  ]);
};
const App = () => {
  const counterContextWrapper = contextWrapper<CounterContext>();
  provide(CounterContextTemplate, counterContextWrapper);
  effect(() => {
    return () => {
      clearWrapper(counterContextWrapper); // Destroy the linker when this component unmounts
      // This will delete it's data and will be marked as destroyed
    };
  });
  return Component(counterConsumerTemplate, { counterLinker });
};
renderNodes(document.body!, Component(App));

onProvide and onDestroy

When providing your Context it may need arguments; they can be passed through the provide function and they are defined by the ContextLinker generic:

class CounterContextTemplate
  extends ContextLinker<{ initialCounter: number }>
  implements CounterContext
{
  public counter = 0;
  public onProvide: ((args: { initialCounter: number }) => void) | undefined = (
    args
  ) => {
    this.counter = args.initialCounter;
  };
  public increase() {
    this.counter++;
    this.update();
  }
  public decrease() {
    this.counter--;
    this.update();
  }
}

provide(CounterContextTemplate, counterContextWrapper, { initialCounter: 100 });

In the same way, when you need to perform some clean-up when the Context is destroyed with clearWrapper, use onDestroy:

class CounterContextTemplate
  extends ContextLinker<{ initialCounter: number }>
  implements CounterContext
{
  public counter = 0;
  public onDestroy: VoidFunction | undefined = () => {
    // Perform a clean-up
  };
  public increase() {
    this.counter++;
    this.update();
  }
  public decrease() {
    this.counter--;
    this.update();
  }
}

Provide and Consume

When providing context, you can provide arguments. The fourth last arguments is forcing provide. provide does not overwrite a context if it's already defined, pass this argument if you want to overwrite.

Sometimes you need to consume a Context inside a Contex Template or Component but avoiding the subscription. You can call consume providing a second argument, which defines if the execution context has to be linked or not (true by default). Passing false will let you access the data from the Context and without subscribing. Inside of contexts there is no execution context; if consuming, always pass the second argument false.

class CounterContextTemplate extends ContextLinker implements CounterContext {
  public counter = 0;
  public increase() {
    const anotherContext = consume(anotherLinker, false);
    this.counter++;
    this.update();
  }
  public decrease() {
    this.counter--;
    this.update();
  }
}

Contexts can be also used outside of components or other contexts, as long as the context has been provided:

function doWatheverOutsideContext() {
  const myContext = consume(contextLinker, false);
  ...
}
1.1.2

5 months ago

1.1.1

5 months ago

1.1.0

5 months ago

1.0.4

5 months ago

1.0.3

6 months ago

1.0.2

6 months ago

1.0.1

6 months ago

1.0.0

6 months ago