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:
- They must have
typeproperty with a string value. - 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,BigIntis 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:
errordeniedunknownTypewrongChannel
A developer can create logux/undo at any moment on the server even after logux/processed was sent.
server.undo(action, meta, 'too late')
self.undo('too late')
Logux.undo(meta, reason: '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' })
store.commit.sync({ type: 'logux/undo', id: meta.id, action: action, reason: 'too late' })
client.log.add({ type: 'logux/undo', id: meta.id, action: action, reason: 'too late' }, { sync: true })
action.reason describes the reason for reverting. There are only two build-in values:
deniedifaccess()callback on the server was not passederroron error during processing.
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.
-
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.
-
Local action with metadata. Action will not be sent to the server or another browser tab. Compare to standard Redux way,
dispatch.localcan set action’s meta.store.dispatch.local(action, meta) -
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. -
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.
There are four ways to add action to Logux Vuex.
-
The standard Vuex way to commit mutations. 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.commit(action) store.commit(type, payload)This way is the best for small UI states, like to open/close menu.
-
Local action with metadata. Action will not be sent to the server or another browser tab. Compare to standard Vuex way,
commit.localcan set action’s meta.store.commit.local(action, meta) -
Cross-tab action. It sends action to all tabs in this browser.
store.commit.crossTab(action) store.commit.crossTab(action, meta)This method is the best for local data like client settings, which you will save to
localStorage. -
Server actions. It sends action to the server and all tabs in this browser.
store.commit.sync(action) store.commit.sync(action, meta)This method is the best for models. For instance, when the user adds a new comment or changed the post.
-
Local action. Action will not be sent to the server or another browser tab.
client.log.add(action, { tab: client.id })This way is the best for small UI states, like opened/closed menu state.
-
Cross-tab action. It sends action to all tabs in this browser.
client.log.add(action, meta)This method is the best to work with local data like client settings, which you will save to
localStorage. -
Send to server. It sends action to the server and all tabs in this browser.
client.log.add(action, { sync: true })This method is the best for working with 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.
Actions added by commit.sync() and commit.crossTab() will be visible to all browser tabs.
// All browser tabs will receive these actions
store.commit.crossTab(action)
store.commit.sync(action)
// Only current browser tab will receive these actions
store.commit(action)
store.commit.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).
Mutations will see cross-tab actions, you do not need to do anything.
Any action without explicit meta.tab will be sent to all browser tabs.
// All browser tabs will receive this action
client.log.add(action)
// Only current browser tab will receive this action
client.log.add(action, { tab: client.tabId })
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).
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 })
store.commit.sync({ type: 'likes/add', postId })
client.log.add({ type: 'likes/add', postId }, { sync: true })
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 })
import { badge, badgeEn } from '@logux/client'
import { badgeStyles } from '@logux/client/badge/styles'
badge(store.client, { messages: badgeMessages, styles: badgeStyles })
import { badge, badgeEn } from '@logux/client'
import { badgeStyles } from '@logux/client/badge/styles'
badge(client, { messages: badgeEn, 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()
showLoader()
try {
await commit.sync({ type: 'likes/add', postId })
} catch {
showError()
}
hideLoader()
showLoader()
try {
await client.sync({ type: 'likes/add', postId })
hideLoader()
} catch {
showError()
}
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)
import { confirm } from '@logux/client'
confirm(store.client)
import { confirm } from '@logux/client'
confirm(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)
},
…
})
class AddLikesAction(ActionCommand):
action_type = 'likes/add'
def access(self, action: Action, meta: Meta) -> bool:
user = User.objects.get(id=meta.user_id)
return not user.is_troll and user.can_read(action['payload']['postId'])
…
# app/logux/policies/channels/likes.rb
module Policies
module Channels
class Likes < Policies::Base
def add?
user = User.find(user_id)
!user.troll? && user.can_read? action[:postId]
end
end
end
end
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.
- Array of strings or string: clients subscribed to any of the listed channels.
channelsorchannel: clients subscribed to any of the listed channels.clientsorclient: clients with listed client IDs.usersorusers: clients with listed user IDs.nodesornodes: clients with listed node IDs.
server.type('likes/add', {
…
resend (ctx, action, meta) {
return `posts/${ action.postId }`
},
…
})
class AddLikesAction(ActionCommand):
action_type = 'likes/add'
…
def resend(self, action: Action, meta: Optional[Meta]) -> List[str]:
return [f"users/{action['payload']['userId']}"]
…
Under construction. Until resend will be implemented in the gem.
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
)
}
})
class AddLikesAction(ActionCommand):
action_type = 'likes/add'
…
def process(self, action: Action, meta: Meta) -> None:
Post.objects.filter(id=action['postId']).update(count=F('likes') + 1)
Under construction. Until resend will be implemented in the gem.
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:
meta.channelsormeta.channel: clients subscribed to any of listed channels.meta.clientsormeta.client: clients with listed client IDs.meta.usersormeta.users: clients with listed user IDs.meta.nodesormeta.nodes: clients with listed node IDs.
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 }
}
})
logux_add function adds Action to Logux and available at any part of code.
from logux.core import logux_add
logux_add({ type: 'someService/error' }, { 'channels': ['admins'] })
You can return actions (action, [action1, action2] or [[action1, meta1]]) in channel’s load method.
class UserChannel(ChannelCommand):
channel_pattern = r'^user/(?P<user_id>\w+)$'
…
def load(self, action: Action, meta: Meta) -> Action:
user = User.objects.get(pk=self.params['user_id'])
return {'type': 'user/add', 'payload': {'user': user} }
some_service.on(:error) do
Logux.add({ type: 'someService/error' }, { channels: ['admins'] })
end
Under construction. Until send_back will be implemented in the gem.
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:
preadd: action is going to be added to the log. It is the only way to setmeta.reasons. This event will not be called for cross-tab actions added in a different browser tab.add: action was added to the log. Do not useclient.log.type()orclient.log.on(). Use onlyclient.type()andclient.on()to get cross-tab actions.clean: action was removed from the log. It will happen if nobody will setmeta.reasonsfor new action or you remove all reasons for old action.
See Server#type and Server#on API docs for server events.