Logux actions are very similar to Redux actions. JSON objects describe what was changed in the application state. If the user does something, the client should create action to change the state. State changes will update UI.

For instance, if user press Like button, your application will create an action:

{ type: 'like/add', postId: 39678 }

Actions are immutable. You can’t change action. If you want to change the data or revert changes, you need to add a new action.

There are only two mandatory requirements for actions:

  1. They must have type property with a string value.
  2. You can use only string, number, boolean, null, array, and object as values. All values should be serializable to JSON. This is why functions, class instances, Symbol, BigInt is prohibited.

Atomic Actions

We recommend keeping actions atomic. It means that action should not contain current state. For instance, it is better to generate likes/add and likes/remove on the client, rather than likes/set with the exact number.

The server can send old action made by another user when this client was offline (for instance, other users will set like to the post too). In this case, Logux Redux will revert own recent actions, add old changes from the server, and replay own actions again. As a result, action will be applied again to a different state. Atomic likes/add will work great, but non-atomic likes/set will override other changes.

You can use CRDT as inspiration to create atomic actions.

System Actions

Logux has a few built-in actions with logux/ prefix.

logux/processed

{ type: 'logux/processed', id: '1560954012838 380:Y7bysd:O0ETfc 0' }

Logux Server response with logux/processed when it received and processed the action from the client. action.id of logux/processed will be equal to meta.id of received action.

logux/undo

{ type: 'logux/undo', id: '1560954012838 380:Y7bysd:O0ETfc 0', action: { type: 'likes/add' } reason: 'error' }

This action asks clients to revert action. action.id is equal to meta.id of reverted action and action.action is the original reverted action. Logux Server sends this action on any error during action processing. In this case, logux/processed will not be sent.

There are 4 standard reason values in the action:

A developer can create logux/undo at any moment on the server even after logux/processed was sent.

  • server.undo(action, meta, 'too late')

    Clients can also create logux/undo to revert action and ask other clients to revert it (if the developer allowed to re-send these actions on the server).

  • store.dispatch.sync({ type: 'logux/undo', id: meta.id, action: action, reason: 'too late' })

    action.reason describes the reason for reverting. There are only two build-in values:

    Developers can use any other reason.

    logux/subscribe

    { type: 'logux/subscribe', channel: 'users/380' }

    Clients use this action to subscribe to a channel. Next, we will have special chapter about channels and subscriptions.

    Developers can define additional custom properties in subscribe action:

    { type: 'logux/subscribe', channel: 'users/380', fields: ['name'] }

    logux/unsubscribe

    { type: 'logux/unsubscribe', channel: 'users/380' }

    Of course, clients also have an action to unsubscribe from channels. It can have additional custom properties as well.

    Adding Actions on the Client

    Adding actions to the log is the only way to change application state in Logux. The log is append-only. You can add action, but can’t change added action or change the state by removing actions from the log.

  • There are four ways to add action to Logux Redux.

    1. The standard Redux way to dispatch actions. Action will not be sent to the server or another browser tab. There is no way to set action’s meta in this method.

      store.dispatch(action)

      This way is the best for small UI states, like to open/close menu.

    2. Local action with metadata. Action will not be sent to the server or another browser tab. Compare to standard Redux way, dispatch.local can set action’s meta.

      store.dispatch.local(action, meta)
    3. Cross-tab action. It sends action to all tabs in this browser.

      store.dispatch.crossTab(action)
      store.dispatch.crossTab(action, meta)

      This method is the best for local data like client settings, which you will save to localStorage.

    4. Server actions. It sends action to the server and all tabs in this browser.

      store.dispatch.sync(action)
      store.dispatch.sync(action, meta)

      This method is the best for models. For instance, when the user adds a new comment or changed the post.

    Sending Actions to Another Browser Tab

  • Actions added by dispatch.sync() and dispatch.crossTab() will be visible to all browser tabs.

    // All browser tabs will receive these actions
    store.dispatch.crossTab(action)
    store.dispatch.sync(action)
    
    // Only current browser tab will receive these actions
    store.dispatch(action)
    store.dispatch.local(action)

    client.log.type(type, fn) and client.log.on('add', fn) will not see cross-tab actions. You must set listeners by client.on(type, fn) and client.on('add', fn).

    Reducers will see cross-tab actions, you do not need to do anything.

    Sending Actions from Client to Server

    When you added a new action to the log, Logux will update the application state and will try to send the action to the server in the background. If the client doesn’t have an Internet connection, Logux will keep the action in the memory and will send action to the server automatically, when the client gets the connection.

    We recommend to use Optimistic UI: do not show loaders when a user changed data (save the form and press a Like button).

  • store.dispatch.sync({ type: 'likes/add', postId })

    You could use badge() or status() to show small notice if Logux is waiting for an Internet to save changes.

  • import { badge, badgeEn } from '@logux/client'
    import { badgeStyles } from '@logux/client/badge/styles'
    
    badge(store.client, { messages: badgeMessages, styles: badgeStyles })

    But, of course, you can use “pessimistic” UI for critical actions like payment:

  • showLoader()
    try {
      await dispatch.sync({ type: 'likes/add', postId })
    } catch {
      showError()
    }
    hideLoader()

    By default, Logux will forget all unsaved actions if the user will close the browser before getting the Internet. You can change the log store to IndexedStore or you can show a warning to prevent closing browser:

  • import { confirm } from '@logux/client'
    confirm(store.client)

    Permissions Check

    Logux Server rejects any action if it was not explicitly allowed by developer:

  • server.type('likes/add', {
      async access (ctx, action, meta) {
        let user = db.findUser(ctx.userId)
        return !user.isTroll && user.canRead(action.postId)
      },
      …
    })

    If server refused the action, it would send logux/undo action with reason: 'denied'. Logux Redux would remove the action from history and replay application state.

    Then the server send actions to all channels and clients from next resend step. In the same time it will accept the action to the database. When changes are saved, the server will send logux/process action back to the client.

    Sending Received Action to Clients

    In special callback, server marks who will receive the actions. For instance, if Alice wrote a message to the chat, server will mark her actions to be send to call users in subscribed to this chat room.

  • server.type('likes/add', {
      …
      resend (ctx, action, meta) {
        return `posts/${ action.postId }`
      },
      …
    })

    Changing Database According to Action

    In last callback server changes database according to the new action.

  • server.type('likes/add', {
      …
      async process (ctx, action, meta) {
        await db.query(
          'UPDATE posts SET like = like + 1 WHERE post.id = ?', action.postId
        )
      }
    })

    Adding Actions on the Server

    The server adds actions to its log to send these actions to clients. There are four ways to specify receivers of new action:

  • The most universal way is:

    someService.on('error', () => {
      server.log.add({ type: 'someService/error' }, { channels: ['admins'] })
    })

    But, in most of the cases, you will return actions in channel load callback. You can return action, [action1, action2] or [[action1, meta1], [action2, meta2]].

    server.channel('user/:id', {
      …
      async load (ctx, action, meta) {
        ler user = await db.first('users', { id: ctx.params.id })
        return { type: 'users/add', user }
      }
    })

    Sending Actions from Server to Client

    When you add a new action to the server’s log, the server will try to send it to all connected clients according to meta.channels, meta.users, meta.clients and meta.nodes.

    By default, the server doesn’t keep actions in the log for offline users to make scaling easy. You can change it by setting reasons on preadd and removing it processed events.

    We recommend to use subscription rather than working with reasons. Every time a client will connect to the server, it sends logux/subscribe again. The server can load the latest state from the database and send it back.

    Events

    Logux uses Nano Events API to add and remove event listener.

    If you need an action with specific action.type use faster client.type method:

    client.on(type, (action, meta) => {
      …
    }, event)

    If you need all action, you can use client.on:

    client.on(event, (action, meta) => {
      …
    })

    Events:

    See Server#type and Server#on API docs for server events.