Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/jarohen/oak
A ClojureScript library to structure single-page apps - taking inspiration from the Elm Architecture
https://github.com/jarohen/oak
clojurescript elm reagent spa
Last synced: 3 months ago
JSON representation
A ClojureScript library to structure single-page apps - taking inspiration from the Elm Architecture
- Host: GitHub
- URL: https://github.com/jarohen/oak
- Owner: jarohen
- Created: 2016-09-08T08:36:00.000Z (over 8 years ago)
- Default Branch: master
- Last Pushed: 2018-08-18T22:15:52.000Z (over 6 years ago)
- Last Synced: 2024-05-01T19:19:36.380Z (9 months ago)
- Topics: clojurescript, elm, reagent, spa
- Language: Clojure
- Homepage:
- Size: 112 KB
- Stars: 13
- Watchers: 6
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.org
Awesome Lists containing this project
README
* Oak
A ClojureScript library to structure single-page [[https://github.com/reagent-project/reagent][Reagent]] apps - taking
inspiration from the Elm Architecture.** Concepts
Oak components are supersets of Reagent components - most of what you already
know from Reagent applies here too.*** State management
State management, in CLJS/Reagent applications, has traditionally been handled
in one of two ways:- We /could/ store it in local Reagent 'ratoms', but over time, this has proven
to be problematic - if we want to serialise/re-construct the state of the
whole application, we have to go to each component and extract its individual
state. We'd rather this was available centrally.
- We /could/ store it all in one global ratom. This has the advantage of being
able to serialise the entire app state easily. However, it's not ideal for
state that's logically scoped to a given component - should the component know
where in the global atom to store it? Particularly, if we have multiple
instances of the same component on screen, we don't want every individual
instance to know /exactly/ where to store its state.It seems that neither storing all our state in one atom, nor individually in
each component, is a good solution to this problem.In Oak, therefore, we explicitly distinguish between these areas of state -
state that's relevant to the whole application (usually business entities, known
in Oak as the 'db'), and state that's only relevant to a single component (known
as 'component local').In any given component, Oak exposes both the 'db' and the component's local
state - but, overall, it stores the state of the application in one global
ratom. Best of both worlds, hopefully!*** Events + commands
We update the state by raising events in our DOM elements. Event handlers are
defined separately from components - they are functions that accept the current
state of the world and an event, and return the new state of the world. In Elm,
these are implemented as a top-level function which delegates as necessary; in
Oak, we use multimethods.Event handlers are synchronous - they are expected to return the new state
immediately. Fortunately, they can also call 'commands' - functions that accept
some command data and a callback. When the asynchronous part finishes, we call
the callback, passing it an event value, and the cycle continues.** Creating components
In Oak, we wrap component functions using the ~oak/defc~ macro:
#+BEGIN_SRC clojure
(oak/defc simple-counter []
(let [counter-value (oak/*db* get ::counter 0)]
[:div {:class #{:counter}}
[:div
"The current value of the counter is "
counter-value][:button {:oak/on {:click [::counter-incremented {}]}}
"Increment!"]]))
#+END_SRCA couple of points to note here:
- We accessed the DB using ~oak/*db*~, which accepts ~f & args~ - if we didn't
want the default, we could simply use ~(oak/*db* ::counter)~. When this data
changes, the appropriate components get re-rendered.
- To access component-local state, we'd use ~oak/*local*~ - more on that later.
- When the button's clicked, we raise an Oak event of type
~::counter-incremented~. We can optionally pass a map of parameters to the
event handler.To define an event handler, we implement the ~oak/handle~ defmulti:
#+BEGIN_SRC clojure
(defmethod oak/handle ::counter-incremented [state ev]
(-> state
(oak/update-db update ::counter (fnil inc 0))))
#+END_SRCIn addition to ~update-db~, we also have ~get-db~, ~get-local~ and
~update-local~.The original React event is available in the ~ev~ map, under `:oak/react-ev`.
** Introducing commands - HTTP requests
Event handlers have to return the next state of the world /synchronously/. To
spawn an asynchronous action (for example, an HTTP request) we have to instruct
Oak to run a command. Let's say we want to relay the counter action to our server:#+BEGIN_SRC clojure
;; (:require [oak.http :as http])(defmethod oak/handle ::counter-incremented [state _]
(-> state
(oak/update-db update ::counter (fnil inc 0))
(oak/with-cmds [::http/request! {:method :post,
:url "/api/increment-counter"
:ev [::increment-resp-received]}])))
#+END_SRCThe command data structure is similar to an event - a command type and some
params. Here, the HTTP command is helpfully allowing us to specify another event
that we'd like to be fired when the response is received.To make our own commands, we implement the ~oak/cmd!~ multimethod. For example,
a simplified version of the above HTTP request command could be:#+BEGIN_SRC clojure
(ns oak.http
(:require [oak.core :as oak]
[cljs-http.client :as http]
[cljs.core.async :as a])
(:require-macros [cljs.core.async.macros :refer [go]]))(defmethod oak/cmd! ::request! [{:keys [method url] :as opts} cb]
(go
(let [resp (a/ state
(oak/update-local assoc :input-value (-> ev :oak/react-ev .-target .-value))))(oak/defc my-form []
(let [{:keys [input-value]} (oak/*local* select-keys [:input-value])]
[:form
[:input {:type :text
:value input-value
:oak/on {:change [::input-updated]}}]]))
#+END_SRCThis gets quite boring quite quickly - so, for the simple case, Oak provides
'binds':#+BEGIN_SRC clojure
(oak/defc my-form []
[:form
[:input {:type :text, :oak/bind [:input-value]}]])
#+END_SRCThe 'bind', in this case, is the path into the local state that stores the
current value of that input field.(TODO: implement binds for non-text fields)
*** Component lifecycle
We can attach events to React/Reagent's usual component lifecycle. For example,
to raise an event when a component's about to be mounted, we would write:#+BEGIN_SRC clojure
(defmethod oak/handle ::my-component-will-mount [state _]
...)(oak/defc my-component [...]
{:oak/on {:component-will-mount [::my-component-will-mount {...}]}}[:div.my-component
...])
#+END_SRCA common use-case here is to set up some state when the component mounts, and
tear it down when the component un-mounts. Fortunately, given this is such a
common use-case, we provide an ~:oak/transients~ option, where you can set up
transient component state:#+BEGIN_SRC clojure
(oak/defc my-component [...]
{:oak/transients [{:keys [selected-filter]} {:selected-filter :all}]}[:div
(case selected-filter
:all "you selected all"
:some "you selected some")])
#+END_SRCIn the 'transients' option, we're specifying a binding for our transient state,
and the initial value. Transient state is stored in the local component state,
and is updated in the same way - likewise, it can be used in 'binds'.*** Child → parent communication
Often, child components need to relay something the user's done to their parent
component - let's say, the user's finished with the child component and wants it
to go away. The close button, and hence the responsibility for initially
handling the user action, is on the child component - but the decision for what
to do next (and the state to make it happen) rests with the parent.In Oak, parent components can specify a 'listener event' when calling through to
a child component. When the child component wants to raise an event to their
parent, they call 'notify' within one of their event handlers:#+BEGIN_SRC clojure
(defmethod oak/handle ::child-form-submitted [state ev]
(-> state
(cond-> form-valid? (oak/notify [::notify-child-form-submitted {...}]))))(oak/defc child-component [...]
[:form {:oak/on {:submit [::child-form-submitted {...}]}}
...
[:button {:type :submit}
"Save"]])(defmethod oak/handle [::child-form-listener ::notify-child-form-submitted] [state ev]
(-> state
(update-local assoc :child-visible? false)
...))(oak/defc parent-component [...]
[:div
^{:oak/listener-ev [::child-form-listener {...}]}
[child-component ...]])
#+END_SRCThis allows the child component to be re-used in different contexts - the notify
event becomes part of the child's API, for each parent to handle.(I'm particularly interested in feedback on this, both the concept and the
implementation - there are many, many different ways to handle it!)** Navigation - HTML5 history
Oak provides basic navigation support, backed by [[https://github.com/juxt/bidi][Bidi]]. To set this up, you first
need to initialise it on app startup:#+BEGIN_SRC clojure
(:require [oak.nav :as nav]
[oak.nav.bidi :as nav.bidi])(def bidi-routes
["" {"/home" :home
"/page2" :page-2}])(defmethod oak/handle ::app-mounted [state _]
(-> state
(oak/with-cmd [::nav/init-nav {::nav/router (nav.bidi/->Router bidi-routes)}])))(oak/defc app-root [...]
{:oak/on {:component-will-mount [::app-mounted]}}[:div "Welcome!"])
#+END_SRCYou can then:
- access the current location (in the form of a map containing ~:handler~,
~:route-params~, ~:query-params~ and ~:history-state~) by calling ~(oak/*db*
::nav/location)~.
- change the location in your event handlers, using the ~[::nav/push-location
{:location {...}}]~ and ~[::nav/replace-location {:location {...}}]~ commands.
- generate links - ~[:a (nav/link location) "Link text"]~You can also react to changes in the location using three multimethods -
~nav/handle-mount~, ~nav/handle-change~ and ~nav/handle-unmount~, which have
similar signatures to normal event handlers:#+BEGIN_SRC clojure
(defmethod nav/handle-mount :home [state {:keys [location]}]
(-> state
(oak/with-cmd [::http/request! {...}])))(defmethod nav/handle-change :home [state {:keys [old-location new-location]}]
(-> state
...))(defmethod nav/handle-unmount :home [state {:keys [location]}]
(-> state
;; tear down, if required
...))
#+END_SRCA view is considered to be re-mounted (from a nav point-of-view, even if the
components aren't necessarily re-mounted) if either the handler or the
route-params change - at which point, the old handler is un-mounted and the
new handler mounted. If the query-params or the history-state changes, only
~handle-change~ will be called.* Feedback? Want to contribute?
Yes please! Please submit issues/PRs in the usual Github way. I'm also
contactable through Twitter, or email.If you do want to contribute a larger feature, that's great - but
please let's discuss it before you spend a lot of time implementing
it. If nothing else, I'll likely have thoughts, design ideas, or
helpful pointers :)* Thanks!
Thanks to [[https://github.com/olical][Oliver Caldwell]] and [[https://github.com/krisajenkins][Kris Jenkins]] who have, over the years, contributed
a awful lot to Oak, in the form of thoroughly fruitful discussions and debates!* LICENCE
Copyright © 2018 James Henderson
Oak is distributed under the Eclipse Public License - either version 1.0 or (at
your option) any later version.