@concave/concave

1.7.1 • Public • Published

Concave

CircleCI NPM

...is a tiny JavaScript framework for building "Single Page Applications" (SPAs).

  • Declarative - Components are powered by a ES6 template tag function to easily define templates
  • Reactive - Components will be updated & re-rendered automatically by state changes
  • Batteries included - State management & router are builtin without any dependencies
  • Minimal - ~1.5 KiB minimized & gzipped dependency free ESM module

Spreading pipelines of components to your browser like a concave lense will spread light rays.

Links

Sponsors

The following sponsors help to test, build and maintain this project. Thank you very much for using your services and products to improve this project:

BrowserStack

BrowserStack

Why another framework

Not everyone needs a framework that's bigger as the application code itself. Often I don't need all the features of React, Vue, Angular and so on for a specific app. Every library has it's dark corners where you struggle or need to find a hacky solution. I invented this project to find a solution that's fast enough for most use cases and provide a maintainable art of developing while beeing as small as possible. The story began with ui = view(state) and ended with a concave lense for pipelines.

Use concave

  • Small to medium sized applications
  • Bootstraping web applications (preload real app in the background)
  • Progressive Web App (PWA)

Don't use concave

  • Big sized applications with a lot of components and forms (rerendering will not be performant?)

Package

The concave package on NPM provides CommonJS, UMD or ESM modules:

  • dist/concave.cjs.js
  • dist/concave.umd.js
  • dist/concave.esm.js
npm i @concave/concave

yarn add @concave/concave

Concept

The main concept consists of a pipeline of functions which will be controlled with actions (plain JavaScript objects). If you are familiar with functional programming concepts, then you will recognize that a pipeline in concave is mostly the same as the result of a pipe function in FP.

There are the following predefined factory functions which will return ready to use pipeline functions:

  • createStore >> creates a store pipeline function
  • createRouter >> creates a router pipeline function
  • createRenderer >> creates a renderer pipeline function
  • createComponent >> creates a component pipeline function

They will be composed with createPipeline which returns a pipeline of functions. You can also compose pipelines with createPipeline if you want. This could be interesting if you want to compose different parts of your application.

With runPipeline you start your pipeline and the first action (state) will flow through the functions.

Through all of these composed functions, actions will be passed to control your application. E.g. the state action - when a component receive this action, it will check if it need to update itself and will trigger a render action on the pipeline if neccessary.

This simple concept results in:

  • Unidirectional action flow >> time traveling & easy debugging
  • Pure components >> predictive rendering results
  • Single source of truth store >> only a state reducer can modify the state

Documentation

Actions

Actions are just plain JavaScript objects which flow through your pipelines and control the application.

All internal and external actions are published and accessible:

import { actions } from '@concave/concave'

// actions.state
// actions.routeTo
// actions.routeNew
// ...

Custom actions:

const aCount = {
  type: 'COUNT',
  value: 3
}

... can be dispatched with dispatch(...objects) in pipeline functions:

dispatch(aCount)
dispatch({ type: 'COUNT', value: 3 }, { type: 'COUNT', value: 4 }, ...[{}, {}])

HTML

To render a component in the DOM you can define a render function with the shape (state, props) => { h: html(template), attrs: {...} }:

const renderFn = (state, props) =>
  ({
    h: html
        `<div id="example">
           <p>${state.text}</p>
           <input id="${props.inputID}"/>
         </div>
        `,
    attrs: {
      example: {
        onchange: event => ({ type: 'INPUT', value: event.target.value }),
        onclick: () => console.log('clicked'),
        style: 'margin-left: 10px;'
      }
    }
  })

If your passed function returns an object, concave will dispatch it as action on the pipeline - every other type will be set as attribute value. To actually use the action to modify the state you have to implement a state reducer - see Store.

Composition

To create a nested tree you can compose the html function:

const renderSub = (state, props) => ({ h: html`<p>I'am sub with text: ${state.text}</p>` })

const renderFn = (state, props) =>
  ({ h: html
      `<div id="${props.componentID}">
         <p>${state.text}</p>
         ${renderSub(state, {})}
       </div>
      `
  )}

Component

To finally create a component you pass the defined render function to createComponent(renderFn, props, hooks, actionReducer):

const component = createComponent(
  renderFn,
  { componentID: 'hello-world' },
  { mounted: dispatch => {}},
  actionReducer
)

Components are first class citizens on pipelines. They will be automatically rendered on state or route changes.

Note: Components must have exactly one root element returned by the render function!

Lifecycle hooks

If you pass the lifecycle function in an object to createComponent(...), this function will be called when the component is inserted in the DOM. You can use it e.g. to fetch data asynchronously and display a loading spinner in the meantime.

Hooks:

hook parameter desc
mounted dispatch called when component is mounted in the DOM
disconnected dispatch called when component is removed from the DOM
const renderFn = () => {...}
const props = {...}

const hooks = {
  mounted: dispatch => {
    dispatch({type: 'GET_DATA'})
  },
  disconnected: dispatch => {...}
}

const component = createComponent(renderFn, props, hooks)

Action reducer

To allow messaging between your components or other pipeline functions you can pass a actionReducer(action, dispatch) to createComponent(...). This function will be called with the actual action and the dispatch function. You can use e.g. to advice another component to do or inform about something.

const renderFn = () => {...}
const props = {
  todos: [{...}, {...}, {...}]
}
const hooks = {...}

const actionReducer = (action, reducer) => {
  switch(action.type) {
    case 'ADD_TODO':
      props.todos.push(action.value) // modify locale state
      dispatch({ type: actions.rerender }) // will trigger rerendering
      break
  }
}

const component = createComponent(renderFn, props, hooks, actionReducer)

Pipeline

The pipeline is the heart of concave. Through pipeline functions actions will flow to manage the state, routing and rendering. To create a pipeline you have to use the factory function createPipeline(...pipelineFns):

const helloPipe = createPipeline(component)

For each route of your SPA you have to create such a component pipeline that will be used after a route change. Please see the Router section to know how to define routes with component pipelines.

For managing the core of your app you have to define at least one base or core pipeline:

const corePipe = createPipeline(appStore, appRouter, appRender)

To actually run a pipeline you have to use runPipeline(corePipe)(initialState) - this will start your application:

const initialState = {
  text: 'Hello World!',
  counter: 0
}

runPipeline(corePipe)(initialState)
// const dispatch = runPipeline(corePipe)(initialState)

Optionally runPipeline(...) will return the dispatch function which will give you access to the applications pipeline.

Custom pipeline functions

If you need to react on your defined and dispatched actions you can implement your own custom pipeline functions to do something with these actions:

const custom = (action, dispatch) => {
  switch (action.type) {
    case 'SOME_ACTION':
      // do something with action.value
      break
  }
  return action
}

const corePipe = createPipeline(appStore, appRouter, appRender, custom)

It's important that your function return the original action at the end, otherwise no trailing pipeline function will receive that action. Custom functions are an better approach to decouple your application logic and state.

const logger = (action, dispatch) => {
  console.log(action)
  return action
}

const corePipe = createPipeline(logger, appStore, appRouter, appRender)

Store

The builtin state management is a single source of truth store which holds just a plain JavaScript object as global state. When the pipeline runs for the first time the initial state will override the stores state. Modifications of the state can only be done with a state reducer. A store can be created with createStore((state, action, dispatch) => action):

const appStore = createStore(
    (state, action, dispatch) => {
      switch (action.type) {
        // state reducer
        case 'COUNT':
          dispatch({
            type: actions.state,
            value: { ...state, counter: state.counter + 1 }
          })
          break
      }
      return action
    }
)

Router

The builtin router is using the History API for saving the actual state of a route in the browser. To create a router you have to use createRoute(routes):

import * as hello from './myHelloPipeline.js'
const helloPipe = () => hello

const routes = [
  {
    fallback: '/hello?name=test' // define a fallback route
  },
  {
    path: '/hello',
    pipe: helloPipe
  },
  {
    path:'/async',
    pipe: () => import('./somePipe.js') // e.g. code splitting with webpack/rollup dynamic import
  }
]

const appRouter = createRouter(routes)

To actually change a route from your application you have to dispatch the action routeTo with the path of the new route as value. If you want to define a new route at runtime you have to dispatch the action routeNew with an array of objects with the new routes:

const routeToAction = {
  type: actions.routeTo,
  value: '/home'
}

const routeNewAction = {
  type: actions.routeNew,
  value: [
    { path: '/newroute', pipe: () => newRoutePipe },
    { path: '/newer/:route', pipe: () => newerRoutePipe }
  ]
}

The route and it's params will be saved on the global state object as _route, so you are able to access them in your components with state._route.params and state._route.query:

/test/123?id=1

const routes =[
  { path: '/test/:name', pipe: () => testPipe }
]

_route: {
  path: 'test',
  params: {
    name: '123'
  },
  query: {
    id: '1'
  }
}

Render

To render your components at a specific mountpoint you have to create a render pipeline function with createRenderer(mountpoint):

const mountpoint = document.getElementById('app')
const appRender = createRenderer(mountpoint)

All components of your defined route pipelines will now be rendered at the DOM node with the ID app, so you must have to define this in your index.html or whatever structure you have:

<div id="app"></div>

Rerendering

Reactivity in concave normally relies on state changes but you can force a rerendering evaluation by dispatching the rerender action that is exposed by actions. If your component's render function e.g. uses local state of the props, concave will not automatically rerender changes if a property changes. You have to manually dispatch the rerender action after changing the property which will lead to a different HTML output.

Browser support

All modern browsers are supported - dinosaur IE11 not!

Readme

Keywords

Package Sidebar

Install

npm i @concave/concave

Weekly Downloads

0

Version

1.7.1

License

MIT

Unpacked Size

110 kB

Total Files

18

Last publish

Collaborators

  • matsp