Reactor enables you to do something similar to Phoenix framework LiveView using Django Channels.
This is no replacement for VueJS or ReactJS, or any JavaScript but it will allow you use all the potential of Django to create interactive front-ends. This method has its drawbacks because if connection is lost to the server the components in the front-end go busted until connection is re-established. But also has some advantages, as everything is server side rendered the interface comes already with meaningful information in the first request response, you can use all the power of Django template without limitations, if connection is lost or a component crashes, the front-end will have enough information to rebuild their state in the last good known state.
Reactor requires Python >=3.9.
Install reactor:
pip install django-reactor
Reactor makes use of django-channels
, by default this one uses an InMemory channel layer which is not capable of a real broadcasting, so you might wanna use the Redis one, take a look here: Channel Layers
Add reactor
and channels
to your INSTALLED_APPS
before the Django applications so channels can override the runserver
command.
INSTALLED_APPS = [
'reactor',
'channels',
...
]
...
ASGI_APPLICATION = 'project_name.asgi.application'
and modify your project_name/asgi.py
file like:
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project_name.settings')
import django
django.setup()
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from django.core.asgi import get_asgi_application
from reactor.urls import websocket_urlpatterns
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
})
Note: Reactor since version 2, autoloads any live.py
file in your applications with the hope to find there Reactor Components so they get registered and can be instantiated.
In the templates where you want to use reactive components you have to load the reactor static files. So do something like this so the right JavaScript gets loaded:
{% load reactor %}
<!DOCTYPE html>
<html>
<head>
...
{% reactor_header %}
</head>
...
</html>
Don't worry if you put this as early as possible, the scripts are loaded using <script defer>
so they will be downloaded in parallel with the html, and when all is loaded they are executed.
In your app create a template x-counter.html
:
{% load reactor %}
<div {% tag_header %}>
{{ amount }}
<button {% on 'click' 'inc' %}>+</button>
<button {% on 'click' 'dec' %}>-</button>
<button {% on 'click' 'set_to' amount=0 %}>reset</button>
</div>
Anatomy of a template: each component should be a custom web component that inherits from HTMLElement. They should have an id
so the backend knows which instance is this one and a state
attribute with the necessary information to recreate the full state of the component on first render and in case of re-connection to the back-end.
Render things as usually, so you can use full Django template language, trans
, if
, for
and so on. Just keep in mind that the instance of the component is referred as this
.
Forwarding events to the back-end: Notice that for event binding in-line JavaScript is used on the event handler of the HTML elements. How does this work? When the increment button receives a click event send(this, 'inc')
is called, send
is a reactor function that will look for the parent custom component and will dispatch to it the inc
message, or the set_to
message and its parameters {amount: 0}
. The custom element then will send this message to the back-end, where the state of the component will change and then will be re-rendered back to the front-end. In the front-end morphdom
(just like in Phoenix LiveView) is used to apply the new HTML.
Now let's write the behavior part of the component in live.py
:
from reactor.component import Component
class XCounter(Component):
_template_name = 'x-counter.html'
amount: int = 0
def recv_inc(self):
self.amount += 1
def recv_dec(self):
self.amount -= 1
def recv_set_to(self, amount: int):
self.amount = amount
Let's now render this counter, expose a normal view that renders HTML, like:
def index(request):
return render(request, 'index.html')
And the index template being:
{% load reactor %}
<!DOCTYPE html>
<html>
<head>
.... {% reactor_header %}
</head>
<body>
{% component 'XCounter' %}
<!-- or passing an initial state -->
{% component 'XCounter' amount=100 %}
</body>
</html>
Don't forget to update your urls.py
to call the index view.
Add:
...
class XCounter(Component):
_url_params = {"amount": "counter_amount"} # local attr -> get parameter name
...
This will make it so when everytime amount is updated the URL will get replaced updating the GET parameter ?&counter_amount=20
(in case counter=20). So the user can copy that URL and share it, or navigate back to it and you can retrieve that GET parameter and restore the state of the component.
...
<body>
{% component 'XCounter' amount=request.GET.counter_amount|default:0 %}
</body>
...
Default settings of reactor are:
REACTOR = {
"USE_HTML_DIFF": True,
"USE_HMIN": False,
"BOOST_PAGES": False,
"RECEIVER_PREFIX": "recv_",
"TRANSPILER_CACHE_NAME": "reactor:transpiler",
"AUTO_BROADCAST": False,
}
USE_HTML_DIFF
: when enabled usesdifflib
to create diffs to patch the front-end, reducing bandwidth. If disabled it sends the full HTML content every time.REACTOR_USE_HMIN
: when enabled and django-hmin is installed will use it to minified the HTML of the components and save bandwidth.RECEIVER_PREFIX
: is the prefix of the event handlers of the components.TRANSPILER_CACHE_NAME
: which django cache (by name) to use for the event handler transpiler cache. This cache will be accessed with very high frequency so is advisable to use something that works in local memory. By defaultLocMemCache(params={"timeout": 3600, "max_entries": 1024, "cull_frequency": 32})
is used if no cache is configured for this name.AUTO_BROADCAST
: Controls which signals are sent toComponent.mutation
when a model is mutated.
{
# model-a
'MODEL': True,
# model-a.1234
'MODEL_PK': True,
# model-b.9876.model-a-set
'RELATED': True,
# model-b.9876.model-a-set
# model-a.1234.model-b-set
'M2M': True,
}
{% reactor_header %}
: that includes the necessary JavaScript to make this library work. ~10Kb of minified JS, compressed with gz or brotli.{% component 'Component' param1=1 param2=2 %}
: Renders a component by its name and passing whatever parameters you put there to theXComponent.new
method that constructs the component instance.{% on 'click' 'event_handler' param1=1 param2=2 %}
: Binds an event handler with paramters to some event. Look at Event binding in the front-endcond
: Allows simple conditional presence of a string:{% cond {'hidden': is_hidden } %}
.class
: Use it to handle conditional classes:<div {% class {'nav_bar': True, 'hidden': is_hidden} %}></div>
.
This happens when in a "normal" template you include a component.
{% component 'Component' param1=1 param2=2 %}
This passes those parameter there to Component.new
that should return the component instance and then the component get's rendered in the template and is sent to the client.
When the component arrives to the front-end if it is a root component (has no parent components) it "joins" the backend. Sends it's serialized state to the backend which rebuilds the component and calls Component.joined
.
After that the component is rendered and the render is sent to the front-end. Why? Because could be that the client was online while some change in the backend happened.
When a component or its parent has joined it can send user events to the client. Using the on
template tag, this events are sent to the backend and then the componet is rendered again.
Every time a component joins or responds to an event the Componet._subscriptions
set is reviewed to check if the component subscribes or not to some channel.
- In case a mutation in a model occurs
Component.mutation(channel: str, instance: Model, action: reactor.auto_broadcast.Action)
will be called. - In case you broadcast a message using
reactor.component.broadcast(channel, **kwargs)
this message will be sent to any component subscribed tochannel
using the methodComponent.notification(channel, **kwargs)
.
If the component is destroyed using the Component.destroy
or just desapears from the front-end it is removed from the backend. If the the websocket closes all components in that connection are removed from the backend and the state of those componets stay just in the front-end in the seralized form awaiting for the front-end to join again.
Each component is a Pydantic model so it can serialize itself. I would advice not to mess with the __init__
method.
Instead use the class method new
to create the instance.
new
: Class method that responsable for the component initialization, pass what ever you need to bootstrap the component state and read from the database. Should return the component instance._extends
: (default:"div"
) Tag name HTML element the component extends. (Each component is a HTML5 component so it should extend some HTML tag)_template_name
: Contains the path of the template of the component._exclude_fields
: (default:{"user", "reactor"}
) Which fields to exclude from state serialization during rendering_url_params
: (default:{}
) Indicates which local attribute should be persisted in the URL as a GET parameter, being the key a local attribute name and the value the name of the GET parameter that will contain the value of the local attribute.
Component caching:
If you set a cache named "reactor"
, that cache will be used for components.
_cache_key
: (default:None
) If defined as a string is used as a cache key for rendering._cache_time
: (default:300
seconds) The retention time of the cache._cache_touch
: (default:True
) If enabled everytime the component is rendered the cache is refreshed extending the retention time.
Transpiled event handler:
If you set a cache named "reactor:transpiler"
that one will be use, by default LocMemCache
is used.
_subscriptions
: (default:set()
) Defines which channels is this component subscribed to.mutation(channel, instance, action)
Called when autobroadcast is enabled and a model you are subscribed to changes.notification(channel, **kwargs)
Called whenreactor.component.broadcast(channel, **kwargs)
is used to send an arbitrary notification to components.
destroy()
: Removes the component from the interface.focus_on(selector: str)
: Makes the front-end look for thatselector
and run.focus()
on it.reactor.freeze()
: Prevents the component from being rendered again.reactor.redirect_to(to, **kwargs)
: Loads a new page and changes the url in the front-end.reactor.replace_to(to, **kwargs)
: Changes the current URL for another one.reactor.push_to(to, **kwargs)
: Like redirect, but instead of loading from the server pushes the content of the page via websocket.reactor.send(_channel: str, _topic: str, **kwargs)
: Sends a message over a channel.
reactor.send(element, name, args)
: Sends a reactor user event toelement
, wherename
is the event handler andargs
is a JS object containing the implicit arguments of the call.
Look at this:
<button {% on "click.prevent" "submit" %}>Submit</button>
Syntax: {% on [] %}
The format for event and modifiers is @<event>[.modifier1][.modifier2][.modifier2-argument1][.modifier2-argument2]
Examples:
{% on "click.ctrl" "decrement" %}>
: Clicking with Ctrl pressed calls "decrement".{% on "click" "increment" amount=1 %}>
: Clicking calls "increment" passingamount=1
as argument.
Misc:
-
event
: is the name of the HTMLElement event:click
,blur
,change
,keypress
,keyup
,keydown
... -
modifier
: can be concatenated after the event name and represent actions or conditions to be met before the event execution. This is very similar as how VueJS does event binding:Available modifiers are:
inlinejs
: takes the next "event handler" argument as literal JS code.prevent
: callsevent.preventDefault()
stop
: callsevent.StopPropagation()
ctrl
,alt
,shift
,meta
: continues processing the event if any of those keys is presseddebounce
: debounces the event, it needs a name for the debounce group and a delay in milliseconds. Example:keypress.debounce.100.search
.key.<keycode>
: continues processing the event if the key withkeycode
is pressedenter
: alias forkey.enter
tab
: alias forkey.tab
delete
: alias forkey.delete
backspace
: alias forkey.backspace
space
: alias forkey.
up
: alias forkey.arrowup
down
: alias forkey.arrowdown
left
: alias forkey.arrowleft
right
: alias forkey.arrowright
Reactor sends the implicit arguments you pass on the on
template tag, but also sends implicit arguments.
The implicit arguments are taken from the form
the element handling the event is in or from the whole component otherwise.
Examples:
Here any event inside that component will have the implicit argument x
being send to the backend.
<div {% tag-header %}>
<input name="x"/>
<button {% on "click" "submit" %}>Send</button>
</div>
Here any submit_x
will send x
, and submit_y
will send just y
.
<div {% tag-header %}>
<input name="x"/>
<button {% on "click" "submit_x" %}>Send</button>
<form>
<input name="y"/>
<button {% on "click.prevent" "submit_y" %}>Send</button>
</form>
</div>
Given:
<button {% on 'click 'inc' amount=2 %}>Increment</button>
You will need an event handler in that component in the back-end:
def recv_inc(self, amount: int):
...
It is good if you annotate the signature so the types are validated and converted if they have to be.
I made a TODO list app using models that signals from the model to the respective channels to update the interface when something gets created, modified or deleted.
This example contains nested components and some more complex interactions than a simple counter, the app is in the /tests/
directory.
Clone the repo and create a virtualenv or any other contained environment, get inside the repo directory, build the development environment and the run tests.
git clone [email protected]:edelvalle/reactor.git
cd reactor
make install
make test
If you want to run the included Django project used for testing do:
make
cd tests
python manage.py runserver
Enjoy!