bacta
TypeScript icon, indicating that this package has built-in type declarations

0.10.6-next.32 • Public • Published

bacta 🔋

💣 Do not use this yet, it is not stable, it is not tested, it is completely experimental.

What

A mithril wrapper that adds :

  • First class reactivity
  • An effects systems
  • CSS helper
  • State management / Store API
  • Built in sum type
  • Nested typed routing
  • Improved typescript support

Think: "Solid.js in mithril" and you're close.

Quick Start

  • npm install bacta
  • Replace import m from 'mithril' with import m from 'bacta'

All existing code will work the same but you'll now have some opt in granular reactivity.

Browser CDN Build

You can try out bacta in a code playground with this single import:

import m from 'https://esm.sh/v117/bacta@latest/es2022/dist/bacta.esm.js'

See example here

Bacta treats mithril as a peer dependency, but this build goes and conveniently includes mithril in the ESM bundle as well.

API Extensions

🤓 bacta is a superset of mithril, so please refer to the excellent API docs on the mithril website for the current stable mithril API.

The following documents the tiny superset of extensions we've layered on top of mithril.

Component API

You can directly return a view function from a closure component instead of needing to return { view }

Legacy mithril:

function Old(){
  return {
    view: () => h('h1', 'Hello')
  }
}

New and improved Bacta ™️

function New(){
  return () => h('h1', 'Hello')
}

If you are using typescript, you may want to use the bacta.component helper:

const Greeting = bacta.component<{ name: string }>(() => ({ attrs }) => 
  m('p', `Hello ${attrs.name}`)
)

It is completely optional, but it makes for a better typed experience than manual annotation of the VNode

If you find that a bit verbose, you can alias it to c

const c = bacta.component

const Greeting = c<{ name: string }>(() => (v) => 
  m('p', `Hello ${v.attrs.name}`)
)

VNode

All components and lifecycle hooks in mithril.js get access to a special object called the vnode (virtual dom node). Because this object is omnipresent in the mithril API we rely on that to add new feature additions.

VNode.useStream

Creates a stream that will automatically end when the component unmounts.

Here's a simple example:

import m, { BactaComponent, Vnode } from 'bacta';

const Example = bacta.component((v) => {
  // this stream won't emit after this component unmounts
  // nor will any streams the depend upon it
  let value = v.useStream(500);

  return view: () => null;
});

VNode.useEffect

A helper to encapsulate create and teardown effects.

v.useEffect({ name: 'example effect' }, function * () {
  while (true) {
    yield new Promise( Y => setTimeout(Y, 1000))
    console.log('hello effect');
  }
});

Within useEffect you can write to the self stream, and this can be used to emit values to the consumer of the effect.

let countStream = v.useEffect({ name: 'count' }, function * ({self}) {
  let count = 0;

  while (true) {

    yield new Promise(Y => setTimeout(Y, 1000))
    
    // emit an incremented number every second
    self(count++)
  }

});

// no initial value
countStream()
// undefined

// logs:
// count 0
// count 1
// count 2 etc
countStream.map( x => console.log('count', x) )

By default this returned stream will have no initial value, and you may get type errors saying your effect stream may be undefined. You can solve this by passing in a seed value as the first argument:

let countStream = v.useEffect({ name: 'count', seed: 0 }, function * ({self}) {
  while (true) {
    yield new Promise(Y => setTimeout(Y, 1000))
    
    self.update( x => x + 1 )
  }
});


// has initial value
countStream()
// 0

// logs:
// count 0
// count 1
// count 2 etc
countStream.map( x => console.log('count', x) )

In addition to a seed, you can subscribe to streams and stores by yielding them. This will wait for a value and subscribe to that stream for all future emits.

async function checkConstraints(value){...}

let proposed = v.useStream('')
let accepted = v.useEffect<string>({ name: 'constraints' }, function * ({self}) {
  try {
    // extract value and react to changes 
    // from here on in if stream is unset
    // we wait here until it emits
    const p = yield proposed
    
    // run an async function
    // if we receive another trigger 
    // while waiting for a promise to resolve
    // we'll cancel this effect and start a new one
    yield checkConstraints(p)

    // constraints satisfied, so write to the world:
    self(p)
  } catch (e) {
    console.error('Constraint failed', e)
  }
})

If you want your effect to update when other dependencies change, you can yield normal mithril stream combinators like .map, .merge, .lift etc. You can also just yield in series, which has no real penalty in practice.

let seed = 0;
let duration = v.useStream(1000)

let countStream = v.useEffect({ name: 'example', seed }, function * ({self}) {
  let id: number;

  let ms = yield duration
  let f = yield factor
  let exp = yield exponent

  while (true) {
    yield new Promise(Y => setTimeout(Y, ms))

    self.update( x => (x + 1 * f) ** exp );
  }
});

But if you want to register your dependencies in one yield you can yield an array ((Stream<any> | Store<any>)[])

let seed = 0;
let duration = v.useStream(1000)

let countStream = v.useEffect({ name: 'example', seed }, function * ({self}) {
  let id: number;

  // subscribe to all the streams/stores in one go
  let [ms, f, exp] = yield [duration, factor, exponent]

  while (true) {
    yield new Promise(Y => setTimeout(Y, ms))

    self.update( x => (x + 1 * f) ** exp );
  }
});

Any time one of your dependencies emit, your effect will re-run.

If an existing effect is already running, it will be cancelled via:

iterator.return( new Cancellation() )

E.g. let's use a setInterval with a cleanup:

v.useEffect({ name: 'example',  seed }, function * () {
  let id;
  try {
    let id: number;
  
    id = setInterval(() => {
      // ...
    }, 1000)

    // block forever to allow interval to run
    yield new Promise(() => {})
  } finally {
    clearInterval(id)
    // finally will run:
    // - if the effect runs to completion
    // - or if there was an exception
    // - or if bacta cancels the effect because:
    //  - the component unmounted
    //  - one of the dependencies emitted
  }
});

There are also multiple callbacks for different exit / entry states on the effect context passed in as a parameter to the effect:

  • onReturn((data) => { ... }) called when your effect exits without any exceptions
  • onThrow((error) => { ... }) called when your effect exits via throwing an exception
  • onFinally(() => { ... }) called when your effect exits via return or via an exception
  • onReplace(() => { ... }) called when a new instance of your effect is starting

So we could rewrite the above example with onFinally like this:

v.useEffect({ name: 'example', seed }, function * (ctx) {
  let id;
  let id: number;

  id = setInterval(() => {
    // ...
  }, 1000)

  ctx.onFinally(() => clearInterval(id))

  // block forever to allow interval to run
  yield new Promise(() => {})
});

onReplace can be handy when you want to do clean up only at the last possible moment.

const data$ = v.useEffect({ name: 'widget' }, function * (){

  // restart whenever these streams change
  let [some, dependencies] = yield [some$, dependencies$]

  let imperativeThing = new ImperativeThing(some)
  let imperativeWidget = new ImperativeWidget(dependencies)

  onReplace(() => {
    imperativeThing.close()  
    imperativeWidget.close()
  })

  return { 
    ...some
    ,dependencies
    ,imperativeThing
    ,imperativeWidget
  }
})

In the above example we want our imperativeThing and imperativeWidget to continue to exist long after we've returned our object. We only want to clean up these instances when we're creating a new instance.

Our onReplace will run immediately before the next effect is initialized, or when the component is unmounting.

Because an effect returns a Stream you can permanently end an effect by calling stream.end(true) just like any other stream.

// end the effect
// this will trigger any logic in `finally` as well
countStream.end(true);

Here is a complete useInterval example inspired by Dan Abramov's blog post

import m, { BactaComponent, Vnode } from 'bacta';

function useInterval(v: Vnode) {
  let duration = useStream(0);

  let interval = v.useEffect({ name: 'example', seed: 0 }, function * ({self}) {
    let ms = yield duration

    while (true) {
      yield new Promise( Y => setTimeout(Y, ms) )
      self.update( x => x + 1 )
    }
  });

  return { interval, duration }
}

const Example = bacta.component((v) => {
  
  let { interval, duration } = useInterval(v)

  return () => 
    m('.example'
      , m('p', 'Interval has fired')
      , m('p'
        m('label'
          ,'Interval Duration'
          , m('input[type=range]', { $oninput: { value: duration }})
        )
      )
      , m('span', () => duration.map( x => `${x}ms`))
    )
});

🎉 Note this example never requires a VDOM redraw.

Usually you want to emit multiple values without re-initializing the effect, and in this case you write to self. But if your effect only has one result per invocation, you can just return the result.

E.g. this affect emits some DOM, any time the remaining stream emits

const viewStream = v.useEffect({ name: 'view stream' }, function * ({self}) {
    const n : number = yield remaining;
    
    self(
      m('span.todo-count'
        , m('strong'
          , `${n} ${n==1 ? 'item' : 'items'} left`
        )
      )
    )
})

We can just use return here instead

const viewStream = v.useEffect({ name: 'view stream' }, function * ({self}) {
    const n : number = yield remaining;
    
    return (
      m('span.todo-count'
        , m('strong'
          , `${n} ${n==1 ? 'item' : 'items'} left`
        )
      )
    )
})

css

bacta has a built in css-in-js util that supports inlining streams as values.

import m, { css } from 'bacta'

const c = m.stream('pink');
const bc = m.stream('black');

const scale = m.stream(1);

const css = m;

m(
  '.app',
  css`
    & {
      color: ${c};
      background-color: ${bc};

      transform: scale(${scale});
    }     
  `,
  m('input[type=range]', {
    value: scale,
    $oninput: (e) => scale(e.target.valueAsNumber),
  }),
);

Behind the scenes we're replacing every interpolation with a generated css variable.

🤓 The name of the css var is the hash of the css source, so it could forseeably be used with server-side hydration (because it is stateless).

css literals

If you want to inject a literal value into the css context that won't be replaced with a css var you can do so by wrapping the value in css(value)

E.g.

const desktop = css('1000px')

css`
  @media( min-width: ${desktop} ) {
    & {
      grid-template-columns: 1fr 1fr 1fr;
    }
  }
`

Note on subsequent renders or invocations if the value of your literal changes, the sheet won't be updated. If you want dynamic values in your stylesheet use streams or store values.

Literal values are includes in the sheet's identity so if you have two components with identical sheets but different literal values they will generate separate css sheets.

Hyperscript Extensions

Bacta hyperscript is still backwards compatible with mithril but now also supports using mithril streams directly in the view as:

  • attributes
  • style attributes
  • children

You can also use thunks to run a section of view one time only.

And there is new event handler syntax for easy 2 way binding and avoiding mithril's default global redraw behaviour.

Let's walk through it bit by bit. 🕵️

Attributes 📖

You can directly embed streams as attributes in the view.

const Example = bacta.component((v) => {
  let value = v.useStream(500);

  return () => 
    m('input[type=range]'
      , 
      { value // <-- this is a stream
      , min: 0
      , max: 1000
      , oninput: e => value(e.target.value) 
      }
    )
});

When bacta encounters a stream attribute it sets up a stream listener that patches the DOM directly like so:

vnode.oncreate = (v) => {
  value.map((x) => {
    v.dom.value = x;
  });
};

When value changes, our input dom value patches without a redraw or vdom diff.

Style Attributes

You can directly embed streams as attributes on the style object as well. This is helpful for manual binding to named css vars.

For anything more involved it is recommend to use the built in css helper wherever possible.

// in the component
const x = useStream(0);
const y = useStream(0);
const scale = useStream(1);
const rotation = useStream(45);

// in the view
m('player-character', {
  style: {
    '--x': x,
    '--y': y,
    '--scale': scale,
    '--rotation': (x) => rotation.map((x) => `${x}deg`),
  },
});

Children

You can directly embed streams as children in the view.

let now = useStream();

let now = useEffect({ name: 'now' }, ({self}) => {
  while (true) {
    yield new Promise( Y => setTimeout(Y, 1000) )
    return Date.now();
  }
});

m('.app', m('p', 'The time is: ', now));
Unfortunately, because of the way this works it requires some 'rules'.

If you inject a stream child into the view, we need to call m.render(parentElement, childVTree) which means we need to know there's no other child elements that may be mistakenly replaced by that operation.

So before updating we check if the parentElement has any other child elements on first render. And if not, we claim the tree as our own. Otherwise we wrap our node in a bacta-stream-child element and render into that from then on.

So as a rule, if you want to avoid bacta wrapping your stream elements in a child node, ensure there's no other children siblings next to your stream element.

If anyone can think of a clever way to work around this caveat, let me know

Dollar Functions ($onevent)

Mithril has a very cool feature, whenever an event handler fires a global redraw automatically occurs. This means mithril doesn't need to have an opinion on how you manage state. You can use a simple pojo.

With Bacta however, you will often want to prevent mithril from performing a redraw. That is because the view updates without a redraw due to streams being supported directly in hyperscript.

You can prevent mithril from automatically redrawing by prefixing your event handler with a $.

m('button', {
  $onclick() {}, // DOES NOT trigger a global redraw
});

m('button', {
  onclick() {}, // DOES trigger a global redraw
});

We call this feature a "dollar function".

const Example = bacta.component((v) => {
  let value = v.useStream(500);

  return () => [
    m('input[type=range]', {
      value,
      min: 0,
      max: 1000,

      // the $ prefix here tells bacta
      // not to trigger a VDOM redraw
      $oninput: (e) => value(e.target.value),
    }),
    // this paragraph will update 
    // without a global redraw because `value` is
    // a stream.
    m('p', 'Value is', value),
  ]
});

2 way binding

It is possible to manually create a 2 way binding

const value = useStream(false);

m('input[type=checkbox]', {
  // this binds the input value
  // to reactive updates from the stream
  checked: value,

  // this updates the same when 
  // the input value changes
  $oninput: (e) => value(e.target.checked),
});

But a shorthand exists that does exactly the same thing:

const value = useStream(false);

m('input[type=checkbox]', {
  min: 0,
  max: 1000,
  $oninput: { checked: value },
});

If your stream name and event.target property have the same name, you can take advantage of ES6 object shorthand syntax:

const checked = useStream(false);

m('input[type=checkbox]', {
  min: 0,
  max: 1000,
  $oninput: { checked },
});

This works with any type of event, it simply plucks the matching key from event.target and writes it to the corresponding stream. On initialization it sets the dom property to the current value of the stream.

We call this a "dollar binding".

Hyperscript Streams: Thunks

When binding streams to the view you'll clickly find you need to ever so slightly adjust your stream values to work with your view.

It might be text formatting, or adding px to a raw stream value.

In bacta streams created within the view are automatically ended on the next render and unmount. So you can define streams every redraw and you won't run out of memory!

m(
  '.app',
  css`
    & {
        transform: translateX(
          ${x.map( x => `${x}px`)}
        );
    }     
  `,
  m('p', 'Hello', name.map(titleCase)),
);

If you want to be explicit though can you wrap the creation of a stream in a thunk, it will then only be created one time instead of destroyed/recreated every render:

m(
  '.app',
  css`
    & {
        /* subtle difference: () => ... */
        transform: translateX(
          ${() => x.map( x => `${x}px`)}
        );
    }     
  `,
  // subtle difference: () =>
  m('p', 'Hello', () => name.map(titleCase)),
);

The thunk only executes one time. That means you aren't recreating these streams every render. This is definitely more efficient, but it is nice to know if you forget to wrap in a thunk the stream will still be cleaned up automatically.

Stream Extensions

Bacta streams are an API superset of mithril streams, but with some very slight changes in behaviour.

We've also added quite a few new methods to stream.

New accessors:

  • .get
  • .set
  • .update

New combinators:

  • .filter
  • .reject
  • .dropRepeats
  • .dropRepeatsWith
  • .debounce
  • .throttle
  • .awaitAll
  • .awaitLatest

New behaviour:

  • combined streams end when any dependency ends

stream.update

One minor frustation when working with mithril streams is incrementing values requires this little dance:

let count = v.useStream(0);

let inc = () => count(count() + 1);

We would like to provide this alternative:

let count = v.useStream(0);

let inc = () => count((x) => x + 1);

But this would prevent some very useful patterns where we pass a function into a stream (often used by a community favourite meiosis)

So we instead add an explicit update api which only accepts a visitor function, this allows us to increment a count like so:

let count = v.useStream(0);

let inc = () => count.update((x) => x + 1);

This more explicit API also ties in well with our new .get and .set stream APIs.

stream.get / stream.set

bacta/stream's can be read via the traditional getter / setter API used in mithril and flyd

const a = v.useStream(0);

a(); // => 0

a(1);

a(); // => 1

But we've found in practice it can be beneficial to be explicit when getting or setting. Lets say we have a list of streams and we want to turn it into a list of values:

let streams = [a, b, c, d];

// works fine
let values = streams.map((x) => x.get());

// works fine
let values = streams.map((x) => x());

// uh oh, accidentally updated the stream
let values = streams.map(x);

Another scenario: you want to let a component read from a stream, but not write to it.

// can only read
m(UntrustedComponent, { getValue: value.get });

// can read and write
m(UntrustedComponent, { getValue: value });

stream.get is also its own stream, so you can subscribe to changes without having the ability to write back to the source.

someStream.get.map((x) => 
  console.log('someStream changed', x)
);

It is also just a lot easier to grep for, you can more easily distinguish an arbitrary function call from a stream get/set.

stream.filter

interface Filter<T> {
  (predicate: (value: T) => boolean): Stream<T>;
}

Only emits when the user provided predicate function returns a truthy value.

const person = useStream({ name: 'Barney', age: 15 });

setInterval(() => {
  person.update((x) => ({ ...x, age: x.age + 1 }));
}, 86400 * 365 * 1000);

const isAdult = (x) => x.age > 18;
const adult = person.filter(isAdult);

stream.reject

interface Reject<T> {
  (predicate: (value: T) => boolean): Stream<T>;
}

Only emits when the user provided predicate function returns a falsy value.

const child = person.reject(isAdult);

stream.dropRepeats and stream.dropRepeatsWith

Prevents stream emission if the new value is the same as the prior value. You can specify custom equality with dropRepeatsWith.

interface DropRepeats<T> {
  (): Stream<T>;
}
interface DropRepeatsWith<T> {
  (equality: (a: T, b: T) => boolean): Stream<T>;
}
// only send consecutively unique requests to the API 
const results = 
  searchValue.dropRepeats().awaitLatest(async (q) => {
    return m.request('/api/search?q=' + q);
  });

You can also specify equality:

const equality = (a, b) =>
  a.toLowerCase() == b.toLowerCase()

const results = 
  searchValue
    .dropRepeats(equality)
    .awaitLatest(async (q) => {
      return m.request('/api/search?q=' + q);
    });

stream.afterSilence and stream.throttle

interface AfterSilence<T> {
  (ms?: number): Stream<T>;
}
interface Throttle<T> {
  (ms?: number): Stream<T>;
}
// hit the API at most every 300ms
const results1 = 
  searchValue
    .throttle(300)
    .awaitLatest(async (q) => {
      return m.request('/api/search?q=' + q);
    });

// hit the API when searchValue has not emitted any values for at least 300ms
const results2 = 
  searchValue
  .afterSilence(300)
  .awaitLatest(async (q) => {
    return m.request('/api/search?q=' + q);
  });

stream.awaitAll and stream.awaitLatest

interface Await<T> {
  (
    visitor: (value: T) => Promise<T>,
  ): Stream<T>;
}

Converting promises to streams is not as trivial as it seems because a stream doesn't completely semantically capture the domain of a Promise.

We want to encourage you to pick the correct semantic conversion depending on your usecase, so we use longer awkward names than we'd like to force you to consider what you are doing.

Here are the options available:

  • stream.awaitAll: Wait for every promise to resolve, emit unordered results
  • stream.awaitLatest: Wait for the latest emitted promise to resolve, if a new promise is emitted while we're waiting, stop waiting to the old one.

We do not support emitting all results in order as we've never needed that in practice.

You tend to either not care about order, or you only want the latest value (search APIs)

const search = useStream();

const results = search
  .throttle(300)
  .awaitLatest((q) => m.request(`/api/products/search?q=` + q));

In the above example, the user can type quite furiously, but we will at most send a search request every 300ms. We then pipe the search term into awaitLatest. Now if the API takes 500ms to return the list of results, then the user will have already sent a 2nd request to the search endpoint. We then will ignore the response from the first request and now only emit the result of that second newer request. That way we never see a result for an old search query.

timeline
    title awaitLatest
    300ms : /api/search?q=ban 
    400ms : /api/search?q=banana
    500ms : Response(q=ban)
          : Response ignored
    600ms : Response(q=banana)
          : Emits Response(q=banana)

The downside of this is, if the user keeps fiddling with the search query you may never see any results. As each new request cancels the prior request before it can respond.

So you may instead prefer to let every response come through even if its arrives out of order. This is usually the worse choice, but it is needed as a compromise in some cases.

const search = useStream();

const results = search
  .throttle(300)
  .awaitAll((q) => m.request(`/api/products/search?q=` + q));
timeline
    title awaitAll
    300ms : /api/search?q=ban 
    400ms : /api/search?q=banana
    500ms : Response(q=ban)
          : Emits Response(q=ban)
    600ms : Response(q=banana)
          : Emits Response(q=banana)

Now with awaitAll we naively emit every response as it arrives. They may even arrive from the server out of order so be careful. 🐲


We encourage you to handlle errors yourself within the awaitAll / awaitLatest function and coerce the branching model of promises into regular data like so:

const results = search
  .throttle(300)
  .awaitAll((q) => 
    m.request(`/api/products/search?q=` + q)
      .then( 
        value => ({ tag: 'resolved', value })
        , value => ({ tag: 'rejected' value })
      )
  )

This is beneficial because it retains errors in the stream graph and can then be handled with other reactive operators.

But you can also specify an error handler as a second argument, this works well for debugging, logging or routing to a central error stream.

Simple Error Logging:

const results = search
  .throttle(300)
  .awaitAll(
    (q) => m.request(`/api/products/search?q=` + q),
    (error) => console.error(error),
  );

Shared Error Bus:

const error = v.useStream<Error>()

const cats = search
  .throttle(300)
  .awaitAll(
    (q) => m.request(`/api/cat/search?q=` + q),
    error,
  );

const dogs = search
  .throttle(300)
  .awaitAll(
    (q) => m.request(`/api/dogs/search?q=` + q),
    error,
  );

Behaviour Change: Combined Streams

If you create a stream via merge, or lift or combine you are dealing with a combined stream.

These streams are derived from some combination of other streams.

In traditional mithril.js, if one of the dependencies of that combined stream ends, then the combined stream does not end. If other dependencies continue to emit the derived stream continues to emit as well.

In bacta instead we end derived streams when any of the dependencies end. This behaviour aligns with flyd (from which mithril streams were inspired), but also most other applicative's and functors.

This also makes component scoped streams much safer, as you can combine a local scoped stream with a global stream and know you won't have the derived stream continuing to emit once your component unmounts.

Do we really need streams?

In the past I advocated for streams and closure components to be added to mithril. Closure components seem to have been generally celebrated, but it seems the community is not so convinced that streams are actually necessary.

Even the core team will use it occasionally for small parts of their app, but not much more.

My belief is that the source of this view is due to mithril not taking full advantage of streams in its API. If a framework really embraces reactivity then the streams almost disappear into the overall architecture.

Thankfully, recently there's been a surge of interest in signals and reactivity. React now has many mainstream competitor frameworks that have streams at the core of their API including:

  • Solid.js
  • Svelte
  • Preact
  • Vue
  • Angular
  • and many more...

It makes sense the industry is moving in this direction. It is much faster to only update the exact dom property that needs to change in the DOM when that property changes. When we know what changed, we don't need to diff anything to update the DOM.

However, I think VDOM is still incredibly convenient and fast. Throwing VDOM away for a fully reactive view tree seems like a mistake to me.

VDOM is great at creating your initial layout or reconciling a major layout change. While streams in the view are good for updating the values within a layout as it remains largely static. Using both models works surprisingly well.

Think of it like progressive enhancement, we fallback to VDOM when we can't reactively update the view.

Mixing and matching these two paradigms gives you full access to granular reactivity (when it is called for) without needing to jettison the simplicity of mithril.

That's exactly what bacta does.

Store API (Experimental)

The long term play for bacta is to use toto as our state management query language. But to give toto time to breathe and develop as a language, we have a hacky interim typescript API that allows us to simulate what it would be like to have toto with an LSP before we actually do.

We say "hacky", only because it internally relies upon new Function(...) and JS expressions written as template literals. But we already use this store API in multiple projects, and it relatively stable and already very capable.

So, it is safe to use the store API, but be aware, eventually you'll need to replace all your queries with some toto expressions. The hope is though that we can automate that with some AST scrubbing when the time comes.

What is it?

Bacta stores operate on reactive sets. You define the reactive set by specifying what data you are interested in via a query.

You can then read/write any data at that query. You can also subscribe to changes to the results of that query.

Example

Let's use a Todo MVC like application as an example.

type Todo = { id: string, text: string, completed: false }

const $ = v.useStore('myStore', v.useStream({
  todos: [] as Todo[],
}))

const todos$ = $.prop('todos')

todos$.get.map( x => {
  // logs any time the store is changed
  console.log('todos$ changed', x)
})

todos$.get()
//=> []

todos$.set([{ id: '1', text: 'Buy Milk', completed: false}])
//=> [ { id: '1', ... } ]

todos$.update(
  xs => xs.concat({ id: '2', text: 'Buy Bread', completed: false })
)
//=> [{ id: '1', ... }, { id: '2', ... }]

We can further focus our queries, e.g. let's query all the incomplete todos.

const $incomplete =
todos$
.where`
  x => !x.completed
`

This new filtered store as the same API as the root store, so we can .get / .set / .update etc.

Let's mark one of the todos as complete as check that same todo is not in our $incomplete set.

// get the first todo
const first$ = todos$.prop('0')

// check both todos are in $incomplete
$incomplete.get()
// [{ id: '1', ... }, { id: '2', ... }]

// complete the first todo
first$.prop('completed').set(true)


// now $incomplete has 1 less todo in it
$incomplete.get()
// [{ id: '2', ... }]

Let's try a bulk edit. We want to select multiple todos and mark them all complete/incomplete in unison.

To focus on each item in a list, we use .unnest() to focus on each item instead of the list of items:

store
.prop('todos') // [{ id: '1', ... }, { id: '2', ... }]
.unnest() // { id: '1', ... } , { id: '1', ... }

By default when you call .get we only return the first item in a set

store
.prop('todos') // [{ id: '1', ... }, { id: '2', ... }]
.unnest() // { id: '1', ... } , { id: '1', ... }

.get()
// { id: '1', ... }

But you can view all items in the set as a list via .getAll

We can now mark them all as incomplete:

store
.prop('todos') // [{ id: '1', ... }, { id: '2', ... }]
.unnest() // { id: '1', ... } , { id: '1', ... }
.prop('completed') // true, false

// mark all as completed=false
.set(false)

We could also update all the todo text in one go:

store
.prop('todos')
.unnest()
.prop('text')
.update( x => x.toUpperCase() + '!!!' )

Finally we can join stores/streams together reactively.

Let's update our store to have a property that stores ids of rows we have selected.

const $ = v.useStore('myStore', v.useStream({
  selected: string[],
  todos: [] as Todo[],
}))

const $selected = store.prop('selected')
const $todos = store.prop('todos')

// add another todo 
$todos.update(xs => xs.concat({ id: '3', ... }))

// select both todos(id=1,2) but not todos(id=3)
$selected.set([1,2])

Now we can focus on just the todos that are selected:

const $selectedTodo =
  $todos
  .unnest()
  .where`
    x => ${$selected}.includes(x.id)
  `

This new $selectedTodo store will update if any of the todos change, or if the list of selected ids change.

We can now edit only the selected todos:

const toggleSelectedTodos = () =>
  $selectedTodo.prop('completed').update( x => !x )

API

.prop

.get

.set

.update

.patch

Uses an impervious proxy to allow you to mutate an object and have your changes applied immutably to the store.

.unnest()

.where

Typescript

The store works with typescript out of the box, but you may run into problems when using predicates against unions. Typescript will not know you have eliminated a case in a where clause. In this case feel free to use as NewType etc.

import * as T from 'sum-type'


const Auth = T.type('Auth', {
	LoggedOut: (_: any) => _,
	Refresh: (_: any) => _,
	LoggedIn: (_: {
		auth_token: string,
		user_id: string,
	}) => _,
})

const store = v.useStore('example', v.useStream({
  auth: Auth.Refresh({}) as T.Instance<typeof Auth>,
}))

const $loggedIn = 
  (
    store
      .prop('auth')
      .where`x => x.tag === 'LoggedIn` 

      // tell typescript this is a specific case of the union
      as bacta.Store<T.Instance<typeof Auth.LoggedIn>>
  )
  // now we can access the auth_token without getting a type error
  .prop('value')
  .prop('auth_token')

We hope toto's eventual LSP will help us avoid type assertions.

Why use template literals for where?

This is (at least) the 5th evolution of the store API, and we have tried a lot of API variations. When combining predicates with other reactive streams/stores we need a reliable way to know which dependencies are being referenced.

If we skipped the template literal and used arrow functions, we cannot know what dependencies were referenced without relying on tricks like injecting proxies or using signals/reference counting. You also run into interesting but annoying edge cases regarding referencing non reactive values in closures.

In a previous version of the store API we used proxies but gave up on it as we ran into lots of corner cases with typescript and value equality in predicates.

We also tried to use our own fork of S.js which is what Solid.js (and others) use. While very cool in theory we've found signals to be another dead end for us, which may be surprising because of how much other frameworks are moving in this direction. (We'll definitely be writing about this in depth at some point.)

Template literals allow us to avoid all of these problems. The downside is we are reverting to a pre-ESLint, stringly typed world, but the benefits outweigh the problems in our view.

We also already use template literals for other embedded languages such as css and sql.

Finally, when we eventually use toto in bacta, we'll be doing so via template literals anyway and it should be an easier transition because closure references were never possible.

Caveats / Breaking Changes

Functions as children (thunks) get wrapped in ThunkChild

This is technically a breaking change as this only impacts hyperscript elements (not components) and functions as children were never supported for normal HTML elements.

The only way this would impact you is if you are manually modifying the result (the VTree) of executed hyperscript.

Because we use functions as children as thunks, we wrap them in a special internal component called ThunkChild. This prevents them from re-running every render.

Additionally, if you ever inspected vnode.children on the resulting vtree and assumed the value was the exact function you passed to the component, then your component will break too.

We don't like that this is a breaking change. But it is so important for Bacta to be able to create thunks in the view that it is a trade off we have to make.

The only legitimate case I can imagine where this would be an issue is a web component that accepts a function as a child element. But I do not know if that is even supported.

Note, none of this affects components. We never touch vnode.children on a component. It is completely up to the component author to manage their components children API.

Stream Fork

bacta extends the mithril/stream API, unfortunately the only way to do that is to have our own version of the stream library. To avoid having competing stream graphs in your app it is important you replace any imports of import Stream from 'mithril/stream' with import { Stream } from 'bacta'.

bacta/stream is an API superset of the mithril/stream API but they do differ in behaviour ever so slightly.

If any dependency stream ends it will also end any child streams including combinations of streams using operators such as combine, merge, lift etc.

mithril-stream will not end a combined stream if one of the dependencies ends.

If you combine a global stream with a scoped stream in bacta the derived stream will never emit after the scoped stream ends even if the global stream continues to emit.

Unstable

At Harth we are only just starting to use this library instead of our internal fork of mithril. We intend to migrate all projects to this to ensure we're eating our own dogfood. We want to share our own opinionated view on mithril with the community as we think there's lots of exciting new terrain here, but we deliberately avoid certain parts of mithril's API and therefore there is a high probability that there will be unsurfaced bugs as a result.

For example, we never use this bindings, and we do not attempt to preserve this bindings in our hyperscript and component wrappers. We make no effort to support pojo or class variants of mithril components as we only use closure components. We do not use route resolvers, the list goes on.

While bacta is designed to be a superset of mithril, its primary mission is to be a useful library for projects at harth, so will only aim to support patterns that we will actually use on those products.

Readme

Keywords

none

Package Sidebar

Install

npm i bacta

Weekly Downloads

61

Version

0.10.6-next.32

License

ISC

Unpacked Size

414 kB

Total Files

21

Last publish

Collaborators

  • jaforbes