EZui - Reactive UI without the bloat

EZui is not battle tested, just yet.

ezui.useEffect has been deprecated and replaced with a better, more granular system More Info

Tired of UI libraries that have unnecessary amounts of abstractions and hold your hand too much? Try EZui. EZui has near-identical performance to creating the functions for reactive UI yourself (most of the overhead is memory allocation).

So how does it work?

Most UI frameworks create a virtual tree which is then rendered directly by the engine, this approach ensures that the tree remains pure in states, however, this approach is unnecessarily expensive for Roblox, so instead, we just scrap it entirely.

Instead, EZui is assuming you’re creating UIs with Instance.new or :Clone(), so we provide the functions for binding our source objects directly to them (as well as a few utility functions for Children and Tags). This library does not hold your hand, so its possible to create dangerous code. Since we’re not doing expensive table iteration or dynamic accesses*, it means EZui dwarfs every framework currently available.

* we use dynamic allocation for effects, which is slower than we want, but theres not much we can do here

Example Usage

Below is a simple example that updates a counter TextButton when you click it, notice how we use an Instance directly instead of expensively creating new instances.

This assumes there a TextButton has the same parent as the script

local ezui = require(ReplicatedStorage.ezui)
local clickGetter, clickSetter = ezui.source(0)

ezui.useValue(script.Parent.TextLabel, "Text", function()
  return `times clicked: {clickGetter()}`
end)

ezui.connectEvent(script.Parent.TextLabel, "Activated", function(senderParams, io, timesClicked)
  clickSetter(timesClicked)
end)

Sources and Effects

The core system for our reactive UI is the Source and Effect system, this is inherited from Roblox/Signals, but made a lot simpler.

You can create a source using ezui.source, any value can be pushed into a Source

local getter, setter = ezui.source(0)

The getter function retrieves the captured value, while setter updates it. Sources are lazy-evaluated for performance.

Effects are events that are called when you call a source setter.

local getter, setter = ezui.source(0)

local dispose = ezui.effect(function()
  print(getter())
end)

setter(2)

It’s important to keep a reference to dispose, otherwise you’ll create a memory leak as effects are strongly referenced internally.

You can also put getters inside functions to create computed sources.


local function getName()
  return firstNameGetter() .. " " .. lastNameGetter()
end

This will correctly wire to effects if you call it, as the getters are still called.

Using getters in EZui

You can bind a getter (or a function that contains getters) by using an associated effect binding function.

Effects can control properties, events, change events, tags and children. The passed getter should return the associated value for it to be connected to the instance.

local counterGetter, counterSetter = ezui.source(tick())

local dispose = ezui.useValue(TextLabel, "Text", function()
  return `clicked {counterSetter} time(s)`
end)

ezui.connectEvent(TextLabel, "Activated", function(senderParams, io, timesClicked)
  counterSetter(timesClicked)
end)

Like effects, it is important to keep a reference to dispose, calling this disposer will NOT disconnect events bound through the effect, which is intentional, set the source value to nil first to disconnect it.

All events and effects are automatically disposed when an Instance is destroyed. This means when the Instance fires Instance.Destroying (which is not fired by some internal destruction events.)

Utility functions

As well as effects, EZui provides utility functions to make it easier to program some parts of the UI. These are provided below.

ezui.connectEvent

Param Type Description
inst Instance The instance to connect to
eventName string The event name to connect to
fn (SenderParams, ...any) -> () The event function

Returns: dispose - A function to disconnect the event.

Connects an event on the Instance, and provides SenderParams, as well as the params of the event.

ezui.connectChangeSignal

Param Type Description
inst Instance The instance to connect to
propName string The prop name to listen for changes on
fn (SenderParams, propVal: any, propName: string) -> () The event function

Returns: dispose - A function to disconnect the event.

Connects a change event to the instance, passes the changed value and prop name, as well as the SenderParams struct.

ezui.setChildren

Param Type Description
inst Instance The instance to update
children {Instance} The new children

Updates Children on an instance, only updates changed values. Removed instances are parented to nil and not destroyed.

ezui.setTags

Param Type Description
inst Instance The instance to update
tags {string} The new tags

Updates CollectionService tags on an instance, only updats changed values.

SenderParams

Events send a struct called SenderParams, which contains the sender and a function to disconnect the event.

4 Likes

any preview?? im interested
thanks

2 Likes

Looks great! I can see this being useful.

Before Edit

It’s unclear from the post what EZui does. You should add some documentation and code samples here for us to understand why it’s a better than the alternatives.

Here is an example of a post which accomplishes these goals. It uses the Roblox documentation style for familiarity for the reader, but this isn’t necessary.

(cc @iDevYoyo)

I’ll get around to this when I find the time.

EDIT: added

2 Likes

Updated!

  • Setter no longer reinvokes effects if the value didn’t change (tables bypass this check).
  • Event effects can now be unset by setting the getter to nil.
  • Changed casing of module from EZUI → ezui
  • Export SenderParams type.
1 Like

This looks great, I’ll definitely have to check it out

1 Like

Heads up, I’m deprecating useEffect and splitting up the if chain into seperate methods. This is done mainly for performance (since the if chain was quite expensive), but also to improve typechecking since we know what type the getter should be ahead of time, instead of needing to rely on the otherwise entropic type getter<unknown>

It additionally makes it easier to implement an upcoming optimisation to effects when the effect is one of these built-in logic paths.

The associated replacement functions are

  • $children → ezui.useChildren,
  • $tags → ezui.useTags,
  • @propName → ezui.useChangeEvent,
  • propName (where propName is an event) → ezui.useEvent,
  • propName (where propName is a property) → ezui.useValue,

This update also adds getterUnion which makes it possible to define components as a value directly, or as a union of that value with a getter.

type Window = {
  Size: ezui.getterUnion<UDim2>
}

...
ezui.useValueUnion(frame, "Size", options.Size)

I don’t like making big library changes like this, but this should be the only time it happens for a while.