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
type
property 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
,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:
error
denied
unknownType
wrongChannel
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:
denied
ifaccess()
callback on the server was not passederror
on 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.local
can 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.local
can 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.
channels
orchannel
: clients subscribed to any of the listed channels.clients
orclient
: clients with listed client IDs.users
orusers
: clients with listed user IDs.nodes
ornodes
: 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.channels
ormeta.channel
: clients subscribed to any of listed channels.meta.clients
ormeta.client
: clients with listed client IDs.meta.users
ormeta.users
: clients with listed user IDs.meta.nodes
ormeta.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.reasons
for new action or you remove all reasons for old action.
See Server#type
and Server#on
API docs for server events.