pulsor

@pulsor/core

Core runtime for the pulsor framework

Getting started

Installation

npm install @pulsor/core

Basic usage

In pulsor, your app is a single tree of vNodes that describes your whole app. You pass this "definition" to the run function, along with a mount node to start off the client runtime.

import { run } from '@pulsor/core';

const app: VNode = // app definition ...

run(app, document.body);

The mount node can be a standard DOM element, or a NormalizedVNode object for hydration purposes.

You write pulsor apps using these 2 building blocks:

VNodes

Syntax

You could checkout the type definition of the VNode object here, but we think code examples are a better way to give you an idea of what vNodes look like. Below are some examples using different syntaxes that you can choose from.

Raw VNode syntax

import { run } from '@pulsor/core';

// App definition
const app: VNode = {
  tag: 'div',
  children: [
    {
      tag: 'h1',
      children: 'Hello world'
    },
    {
      tag: 'img',
      props: {  
        src: 'https://cool-site.com/frog.jpg',
        alt: 'Frog doing backflip'
      }
    },
  ]
};

// Run app
run(app, document.body);

Using the h helper

const app = (
  h('div', {}, [
    h('h1', {}, 'Hello world'),
    h('img', {
      src: 'https://cool-site.com/frog.jpg',
      alt: 'Frog doing backflip'
    }),
  ])
);

Using JSX

const app = (
  <div>
    <h1>Hello world</h1>
    <img src="https://cool-site.com/frog.jpg" alt="Frog doing backflip" />
  </div>
);

Dynamic rendering

So far, above examples were only displaying static data, but the real power of pulsor comes from dynamically rendering the UI based on some current state.

This is possible in pulsor by passing a function for the children property of a vNode in the vNode tree. This children function will receive the current state as a parameter, returning the correct JSX for this given state.

const app = (
  <ul init={{ items: ['a', 'b', 'c'] }}>
    {state => state.items.map(letter => (
      <li>{letter}</li>
    ))}
  </ul>
);

You can add this init property on any elements. It is an Action that is ran before rendering the children elements. More on this later.

Without going to deep into Actions for now, this is an example for a counter:

const app = (
  <main init={{ count: 0 }}>
    <h1>{state => state.count}</h1>
    <button onclick={state => ({ count: state.count - 1 })}>-</button>
    <button onclick={state => ({ count: state.count + 1 })}>+</button>
  </main>
);

Feel free to write the rendering logic however you see fit.

const app = (
  <main
    init={{
      count: 0,
      items: ['a', 'b', 'c'],
    }}
  >
    {state => {
      const { count, items } = state;
      const totalItems = items.length;

      if (count > 100) {
        return (
          <h1>That's a lot of clicking!</h1>
        )
      }

      return (
        <>
          <h1>{count}</h1>
          <button onclick={{ count: count - 1 }}>-</button>
          <button onclick={{ count: count + 1 }}>+</button>
          <p>Total items: {totalItems}</p>
          <ul>
            {items.map(letter => (
              <li>{letter}</li>
            ))}
          </ul>
        </>
      )
    }}
  </main>
);

Named actions and composition is encouraged :

// Actions
const Init = { count: 0 };
const Decrement = (state) => ({ count: state.count - 1 });
const Increment = (state) => ({ count: state.count + 1 });

// Derive state
const CurrentCount = ({ count }) => count;

const app = (
  <main init={Init}>
    <h1>{CurrentCount}</h1>
    <button onclick={Decrement}>-</button>
    <button onclick={Increment}>+</button>
  </main>
);

Props

As you've seen in above examples, you can set DOM props and HTML attributes via either the props object on the VNode, or as a standard JSX prop:

const foo = (state) => (
  <h1
    id="title"
    class={state.isBig ? 'big-title' : undefined}
    data-count={state => state.count}
  >
    I have some props!
  </h1>
)

You can pass a function as well as a prop, which will receive the current state before returning the prop's desired value.

const foo = (
  <h1
    init={{ count: 2, isBig: true }}
    id="title"
    class={state => state.isBig ? 'big-title' : undefined}
    data-count={state => state.count}
  >
    I have some props!
  </h1>
)

Special props

Other than all the native DOM props and HTML attributes you can set, there also some "Special props" in pulsor that adds extra functionnality to your markup.

  • class

The class props can also be an object defining classes to toggle based on some conditions.

const foo = (state) => (
  <h1
    class={{
      'big-title': state.isBig,
      'foo': false,
      'bar': true,
    }}
  >
    Conditional classes!
  </h1>
)
  • style

The style object can accept a CSS object on top of a raw CSS string.

const foo =(
  <h1
    style={{
      backgroundColor: 'red',
      color: 'green',
    }}
  >
    Christmas colors!
  </h1>
)
  • onevent

Any props that starts with on, ex: onclick, will attach an Action to be dispatched when the DOM event occurs on the node.

  • init

The init prop is a life-cycle Action that you can add to any node. The state changes are applied immediately after the node is created, but the important part is that this runs before patching the children nodes, where you can use a function to compute children nodes based on the state.

The side effects here can also return a cleanup function that is ran when the node is removed.

  • clear

The clear props is basically the same as the init prop, except it runs when the nodes are removed.

  • key

The key props, similar to how it is in React, allows you to uniquely identify a vNode during the diff / patch process of the framework.

This allows you to do things like re-order nodes in a list instead of deleting / re-creating them, or combined with the init or clear props, manage life-cycle events of a node properly, or run side-effects based on the state.

const ShuffleItems = (state) => {
  const shuffle = [...state.items];
  shuffle.sort(() => (Math.random() > .5) ? 1 : -1);
  return shuffle;
}

const app = (
  <div>
    <h1>Item shuffle:</h1>
    <ul
      init={{ items: ['a', 'b', 'c'] }}
    >
      {({ items }) => items.map(letter => (
        <li key={letter}>
          {letter}
        </li>
      ))}
    </ul>
    <button onclick={ShuffleItems}>Re-order list!</button>
  </div>
);

Actions

An Action is a simple data structure that represents the only 2 things you can do:

  • Update the state
  • Run side Effects

These are the building blocks of an action.

There are 2 places where you can "hook up" actions in pulsor:

  • Life-cycle events: init, clear
  • DOM events: onclick, onsubmit, onkeydown, etc.

An action is either an Update object, an Effect object, an array of both, a function that returns an action, or a children action.

This structure makes any action easily composable with any other action by just combining them in an array. Updates are ran sequentially, so that an dynamic action based on the state could depend on the resulting state of a previous action.

You can find the TypeScript defininion of Actions here, but once again, code examples makes it easier to understand!

Update

An update object is a javascript object that defines changes to be applied to the state.

// State before: { count: 2 }
const ResetCount = {
  count: 0
}
// State after: { count: 0 }

Values in the object will be recursively "applied" to nested objects

// State before: { user: { firstName: 'John', lastName: 'Doe' } }
const ChangeFirstName = {
  user: {
    firstName: 'Jane'
  }
}
// State after: { user: { firstName: 'Jane', lastName: 'Doe' } }

Values explicitely set to undefined in an update object will remove the property from the state.

// State before: { count: 2, user: { firstName: 'John', lastName: 'Doe' } }
const RemoveSomeFields = {
  count: undefined,
  user: {
    lastName: undefined
  }
}
// State after: { user: { firstName: 'Jane' } }

Effect

An effect is simply an object that has the effect key defined on it. This "side-effects" functions will be queued up, and ran after the the patching process has completed.

const SayHello = {
  effect: () => {
    alert('Hello!');
  }
}

The effects functions will receive a dispatch function as parameter, which will allow them to run actions when external events occur. They can also be async.

const FetchUser = {
  effect: async (dispatch) => {
    const currentUser = await fetchCurrentUser();
    dispatch({
      me: currentUser
    })
  }
}

When an effect is dispatched from an init event, it can optionally return a cleanup function, to clear event listeners or cleanup the memory.

const TrackKeystrokes = {
  effect: (dispatch) => {
    const logKey = (e: KeyboardEvent) => {
      dispatch({ lastKeystroke: e.code })
    }
    document.addEventListener('keydown', logKey)
    return () => {
      document.removeEventListener('keydown', logKey)
    }
  }
}

Composing actions

The power of Actions in pulsor comes from the fact that they can easily be composed (or decomposed) to work together, at a very fine-grained level - all in declarative way.

const ResetCount = { count: 0 };

const SaveLastCount = state => ({ lastCount: state.count });

const StartNewGame = [
  SaveLastCount,
  state => ({ numberOfTimesPlayed: state.numberOfTimesPlayed + 1 }),
  ResetCount,
];

const PreventDefault = (state, ev) => ({
  effect: () => ev.preventDefault()
});

const SaveUserName = (state, ev) => ({
  userName: ev.target.name.value
});

const SaveCountToApi = (state, ev) => ({
  effect: async () => {
    await fetch('/api/counts', {
      method: 'POST',
      body: JSON.stringify({ count: state.count })
    })
  }
});

const SubmitCounterGame = [
  PreventDefault,
  SaveUserName,
  state => state.count !== 0 && [
    SaveCountToApi,
    StartNewGame,
  ]
];

const app = (
  <form onsubmit={SubmitCounterGame}>
    <CounterGame />
    <input type="text" name="user" required />
    <button type="submit">Submit</button>
  </form>
);
Giberish examples to help illustrate the possibilities

All of the variables below are valid actions.

const SimpleUpdate1 = {
  someFieldToChange: 'foo'
}

const SimpleUpdate2 = {
  someOtherField: 'bar',
  someObject: {
    nestedField: 'baz'
  }
}

const SayHelloEffect = {
  effect: () => {
    alert('Hello!');
  }
}

const CreateSomeAction = (text) => state => ({
  someTextBasedOnState: `${text} current count: ${state.count}`
})

const CombinedAction = [
  SimpleUpdate1,
  SimpleUpdate2,
  SayHelloEffect,
  [
    [
      [
        state => [
          { feelFreeToNestThese: 'yay?' }
        ]
      ]
    ]
  ],
  state => state => state => state => [
    { thisWorks: 'but why?' },
    CreateSomeAction('This is why'),
  ],
]

const LogSomeValueLater = (value) => ({
  effect: () => console.log(value)
})

const DynamicAction = state => [
  { someField: 'Hello there' },
  SimpleUpdate1,
  state.someBooleanValue && SimpleUpdate2, // Conditional actions
  state => [
    state.someObject.nestedField === 'baz' ? {
      wow: 'yes',
    } : {
      notWow: 'no'
    }
  ],
  state => state.wow === 'yes' && SayHello,
  state => state.items.map(item => LogSomeValueLater(item))
]



// These two below are parametered actions (functions with a parameter to create the action)

const SetCurrentUser = (user) => ({
  me: user,
})

const BigOlAction = (user) => [
  SetCurrentUser(user),
  DynamicAction,
]