Fuss

FUSS is an experimental functional/reactive framework using strongly-typed streams.
Goals
The main goal is to build a reactive UX framework which:
- Composes nicely: pure functions are the typical case
- Uses strong static typing: compiler errors with wrong usage
- Uses streams as the basis for time-varying data
- Very little magic in the framework
- Framework can co-exist with non-reactive or other implementations of reactive code.
Status
The project is experimental and all interfaces may change
Contents
- Goals
- Status
- TODO MVC Example
- Todo and TodoList
- Generating the streams types
- The Todo View
- Input
- The filtered list
- Change handling and stateful components
- Generating the factory code
- Collaborative demo
- Limitations
TODO MVC Example
The demo TODO MVC
app is built
out of the todo folder.
Todo and TodoList
The core data type for the todo app is a simple struct to hold the
todo item and a slice to represent a collection of these:
// Todo represents an item in the TODO list.
type Todo struct {
ID string
Complete bool
Description string
}
// TodoList represents a collection of todos
type TodoList []Todo
Generating the streams types
The code generation tool
dotc can be used
to augment the types here with additional methods. In particular,
much of the UI will use streams of these values.
func generateTypes() {
_, self, _, _ := runtime.Caller(0)
output := filepath.Join(filepath.Dir(self), "generated1.go")
info := dotc.Info{
Package: "todo",
Structs: []dotc.Struct{{
Recv: "t",
Type: "Todo",
Fields: []dotc.Field{{
Name: "Complete",
Key: "complete",
Type: "bool",
}, {
Name: "Description",
Key: "desc",
Type: "string",
}},
}},
Slices: []dotc.Slice{{
Recv: "t",
Type: "TodoList",
ElemType: "Todo",
}},
}
code, err := info.Generate()
if err != nil {
panic(err)
}
err = ioutil.WriteFile(output, []byte(code), 0644)
if err != nil {
panic(err)
}
}
See
Codegen
where this is used.
The TodoStream
type exposes the current value via the Value
field
and supports an Update()
method to update the value whole-sale. Such
updates do not change the current value but can be thought of as
appending to the sequence of values a particular Todo
has. The
latest value can be obtained via TodoStream:Latest()
and callers can
register for notifications on it.
More interestingly, the code snippet above will generate methods on
the stream to fetch sub-streams for, say, the Complete
field. This
sub-stream holds a simple bool
value and when it is updated, the
corresponding todo
itself is updated with that field changed. The
stream also does "merging" -- if the Complete
and Description
fields were modified indepdently, the latest of the todo
will be
merge of the changes.
The TodoListStream
similarly exposes a TodoStream
for each element
in the array and edits to the individual elements correectly get
propagated to the parent.
The Todo View
The following snippet renders a single todo item:
// Todo renders a Todo item
func todo(deps *todoDeps, todoStream *TodoStream) dom.Element {
return deps.run(
"root",
dom.Styles{},
deps.checkboxEdit("cb", dom.Styles{}, todoStream.Complete(), ""),
deps.textEdit("textedit", dom.Styles{}, todoStream.Description()),
)
}
type TodoFunc = func(key interface{}, todoStream *TodoStream) dom.Element
type todoDeps struct {
run dom.RunFunc
checkboxEdit dom.CheckboxEditFunc
textEdit dom.TextEditFunc
}
Each component has three parts: the core function (todo(..)
), the
set of dependencies used by this function (todoDeps
) and a
signature for how callers can use this function TodoFunc
.
A few notes:
- The first argument of the function must be a pointer to the
dependency struct
- The dependency struct should hold all the other components this one
intends to use. In particular, the function signatures are named
here.
- The signature replaces the first arg with a generic "key" (which
will be explained shortly).
The main function itself creates sub-components as needed using the
dependencies struct. The code-generation framework produces a single
artifact for each compnent -- a factory function that creates
instances of the component:
func NewTodo() (update TodoFunc, close func()) {
...
}
The idea is that any consumer of the todo
component would use this
function above to create an instance. The generated function
implements the logic for creating the dependency functions. In
particular, the dependency functions take a "key" as the first
parameter (with the rest matching the corresponding component). The
generated scaffolding checks if the key was used before and if so
reuses the last instance (calling on its update method). If the key is
new, it calls the factory method of the sub component.
The generated factory function also does simple memoization: if the
args to the update method are same as before, it results the results
from the last round.
Going back to the example above, the checkbox was provided with a
streams.Bool
which controls both whether it shows as checked or not
as well as the output from the checkbox. When the user toggles the
checkbox, the checkbox component updates the provided input stream by
calling streams.Bool:Update(newValue)
on it.
The todo
view does not directly handle this but since the checkbox
stream was created via todoStream.Complete()
any changes to the
boolean stream end up modifying the corresponding todoStream
These changes to the stream do not cause any automatic re-rendering
though. Instead the are propagated up until some point where the
stream is considered the state of a component.
The filtered list
The parent of the todo()
view is a list of todos (filtered by
whether they are active or not based on a filter setting)
// FilteredList renders a list of filtered todos
//
// Individual tasks can be modified underneath.
func filteredList(deps *filteredListDeps, filter *streams.S16, todos *TodoListStream) dom.Element {
return deps.vRun(
"root",
dom.Styles{},
todos.Value.renderTodo(func(index int, t Todo) dom.Element {
done := filter.Value == controls.ShowDone
active := filter.Value == controls.ShowActive
if t.Complete && active || !t.Complete && done {
return nil
}
return deps.todo(t.ID, todos.Item(index))
})...,
)
}
type FilteredListFunc = func(key interface{}, filter *streams.S16, todos *TodoListStream) dom.Element
type filteredListDeps struct {
vRun dom.VRunFunc
todo TodoFunc
}
This code creates a VRun
flex-rows container (using dom.VRun
) but
also takes a stream of TodoList as the arg. It walks through each
element of the TaskList value and creates a child todo component
(assuming the filter conditions were satisfied).
The dependency struct here indicates both sub-components are
needed. The code also uses the task ID as the key when invoking
deps.todo(t.ID, ...)
. This ensures that if an item gets shuffled
around, it will be reused still.
Change handling and stateful components
So far there is no specific code for change handling. The changes
from checkboxes and textedits simply propagaate upwards but no
automatic re-rendering happens until it hits a stateful component.
A stateful component takes a state parameter as well as returns it.
The generated factory function automatically rerenders a component
when its state changes.
An example of such a component is the full app itself: the list of
todos are maintained as state (though in a real app, they would be
saved on the server but thats a different demo):
// App hosts the todo MVC app
func app(deps *appDeps, state *TodoListStream) (*TodoListStream, dom.Element) {
if state == nil {
// TODO: fetch this from the network
state = &TodoListStream{
Stream: streams.New(),
Value: TodoList{
Todo{"one", true, "First task"},
Todo{"two", false, "Second task"},
},
}
}
return state, deps.chrome(
"root",
deps.textView("h", dom.Styles{}, "FUSS TODO"),
deps.listView("root", state),
deps.a(
"a",
dom.Styles{},
"https://github.com/dotchain/fuss",
deps.textView("tv", dom.Styles{}, "github"),
),
)
}
type AppFunc = func(key interface{}) dom.Element
type appDeps struct {
textView dom.TextViewFunc
listView ListViewFunc
a dom.AFunc
chrome controls.ChromeFunc
}
As describe before, the distinguishing marks of a stateful component
are:
-
It has a parameter that captures the state (these should be named
xyzState).
-
It returns the state back. At a minimum, this is needed for
initialization (state is initially the zero value for its type)
When the checkbox stream is updated, that gets propagated all the way
up to the app where the TodoListStream is the state. So, the app
component is re-rendered and effectively this causes the whole
sub-tree to be re-rendered (unless a sub-component has the same args
as before, in which case the previous results are reused).
Generating the factory code
The factory code can be fairly completely auto-generated with a simple
stub like this:
func generateComponents() {
_, self, _, _ := runtime.Caller(0)
output := filepath.Join(filepath.Dir(self), "generated2.go")
skip := []string{"generated2.go"}
info, err := fussy.ParseDir(filepath.Dir(self), "todo", skip)
if err != nil {
panic(err)
}
err = ioutil.WriteFile(output, []byte(fussy.Generate(*info)), 0644)
if err != nil {
panic(err)
}
}
See
Codegen
where this is used.
Collaborative demo
The example in the
collab
folder can be used to demonstrate a collaborative todo list:
Starting the local server:
$ cd github.com/dotchain/fuss/todo/collab
$ go run server
Starting the gopherjs session:
$ gopherjs serve github.com/dotchain/fuss/todo/collab --http=:8081
Now open multiple browser tabs to
http://localhost:8081 and see that they all
share the same TODO list.
The actual todo list is served from a local file (todo.bolt) and so
the state persists even when the browser session is restarted.
Limitations
There are a lot of limitations with this still:
-
The xyzFunc
types must all use named args (as these names are
used within the generated code). State args should not appear on this
Func type.
-
The dependency function can be named anything as its type is
deduced from the first parameter but the xyzFunc
declaration should
match the function name.
-
The actual core function (such as todo
in the example) should
always be unexported. This is also true for the dependency
structures. The xyzFunc
declaration can be exported or private and
the generated NewXyz()
will mirror this.
-
Memoization works by comparing using ==
with one exception:
variadic types are handled by checking each value instead of the
slice. Non-comparable types can implement Equals(other)
methods to
provide custom equals methods.
-
The generated code assumes it lives in the same package. But the
actual code generation should skip this file (the generation code
snippets in this README file correctly deal with this).
-
The dotc code
generator needs to be explicitly provided with the type info -- it is
not deduced from the code yet. This is rather finicky, particularly
if dealing with struct of structs and such as it depends on the
dot infrastructure.
-
The gopherjs bundle is huge. None of the underlying components
have been optimized in any way for the bundle.