(v1.0) Components - Simpler, smarter, modular components

Hero

Components is a simple, streamlined library for implementing highly reusable, flexible custom components for UI, interactive world elements, and more.


What is a component?


Components let you run reusable pieces of code for many different instances in your game at once.

An example of where components can be useful is when defining item pickups in your game. You could write a small piece of code that implements health restoring on touch, then automatically run that code on all your health pickups.

Health Example

This helps you keep organised as your scripts are all kept in one location, rather than having dozens (or even hundreds) of copies scattered all around your game. By updating the code for a component, you can affect the behaviour of everything that uses it immediately.


Quick demonstration


Hereā€™s our initial setup:

Frame 1

Using Components, weā€™ll create a HelloWorld component, which will turn any UI into a rainbow!

First, weā€™ll create a folder inside our local script called ā€˜Componentsā€™. That folder will contain the code for our components.

Then, in that folder, add a ModuleScript called ā€˜HelloWorldā€™. This will store our componentā€™s implementation, in this case some code to make a UI turn into a rainbow:

Frame 2

Notice the OOP style of the component - when we add the HelloWorld component to something, itā€™ll create a new object for us and call construct() with the instance it was added to. When itā€™s removed (or when the instance is destroyed) itā€™ll call destroy() so we can clean up any events we connected to or instances we made.

Now, we just need to write a few lines in our LocalScript. The first line just requires the Components library so we can use it. The second line gives our Components folder to the library, so we can use our HelloWorld component. The third line tells Components to apply our components within ScreenGui.

Frame 3

Now that weā€™ve written our HelloWorld component and written the code to load it in, how do we apply it to something? Itā€™s pretty simple - just add a Configuration instance into it and call it ā€˜HelloWorldā€™.

image 4

Because we passed in our ScreenGui on line 3 of our local script, Components will look for any Configuration instances named ā€˜HelloWorldā€™ in our ScreenGui. If it finds one, itā€™ll apply our code to the instance itā€™s parented to.

Hereā€™s the end result:

export7


Minimal API design


Components aims to be as simple and streamlined as possible. This makes it easier to learn, and reduces the likelihood of bugs and unintended behaviour.

The library is designed to work similarly to the existing Binder coding pattern. Instead of using CollectionService tags (like Binders do), Configuration instances are used, as they can store values, functions and events, and can be easily added and viewed in the Explorer. This makes using components much easier.


Module summary


The following API summary contains only the most common features - these will be enough for 99% of use cases and workflows. Full documentation, features and explanation can be found through comments in the moduleā€™s source code.

function Components.addClassFolder(folder: Instance)

Finds all descendant ClassImages ModuleScript instances and adds them as component classes. The module scriptā€™s name will be used as the component name.

function Components.watch(instance: Instance)

Finds all descendant ClassImages Configuration instances and creates a component for each. The name of the Configuration should be the name of the component to create.

Additionally, this function will listen for new Configuration instances, so you donā€™t have to manually handle creating components for UI thatā€™s added later.


Component API summary


Module scripts for components should return a function with two arguments; the first will be a reference to the Components module, and the second will be an empty ā€˜classā€™ table:

return function(Components, class)
    -- ...
end

Components uses an OOP programming style - a new object is created per component, using the class table as the template.

Your module must implement the class:construct() and class:destroy() methods. If either is missing, your component wonā€™t work and an error will be raised.

construct() is called when the object is first initialised. This occurs when your component is added to an instance.

destroy() is called when the object is about to be destroyed. This occurs when your component is removed from an instance, or when the instance is destroyed.

Make sure you clean up anything you create or connect to inside of destroy() to avoid memory leaks.

return function(Components, class)

    function class:construct(instance: Instance)
        print("Hello, " .. instance.Name .. "!")
    end

    function class:destroy()
        print("Goodbye!")
    end

end
Result

export7

You can add other methods to class, just like a regular OOP setup. Additionally, you can access extra properties in these methods:

  • self.instance - a reference to the instance associated with your component:
    image 9

  • self.config - a reference to the Configuration associated with your component:
    image 10

return function(Components, class)

    function class:construct(instance: Instance)
        self:extraMethod()
    end

    function class:extraMethod()
        print("self.instance = " .. self.instance.Name)
        print("self.config = " .. self.config.Name)
    end

    function class:destroy()

    end

end
Result

image


Build better, build faster, build with Components


Components is 100% free and open source! Attribution in your game, plugin or whatever is appreciated but not required.

You can add Components to your inventory from the Roblox Library.

Alternatively, you can view the full source code right here, including documentation comments:

Source
--[[
	Components
	Simpler, smarter, modular components
	
	by Elttob
--]]

local Components = {}

-- A dictionary of component classes, indexed by name.
Components.classes = {}
-- Fired when a component class is added via Components.addClass()
Components.onClassAdded = Instance.new "BindableEvent"
-- Fired when a component class is removed via Components.removeClass()
Components.onClassRemoved = Instance.new "BindableEvent"

-- A map of component configurations to objects, used internally.
local componentConfigs = {}

--[[
	Attempts to add the given module script as a component.
	
	The module script must return a function.
	That function will be called with two arguments:
		- this Components module
		- a blank 'class' table
	
	The function should add two methods to the class table:
		- a :construct() method
		- a :destroy() method
	If either are missing, an error will be raised.
	
	The :construct() method will be called when a component of this class
	is created for an instance - it will be passed that instance as it's only
	argument.
	
	The :destroy() method will be called when a component of this class is
	destroyed - either because the instance was destroyed, or because
	Components.removeClass() was called for this class.
	
	Other methods can be added to the class table freely if desired. Methods
	defined in the class table (including :construct() and :destroy()) can
	access some convenient utilities via self:
		- self.instance - the instance associated with this component
		- self.config - the Configuration instance for this component
	
	Each component must use a unique name - an error will be raised if another
	component is already using the same name as this one.
	
	After the component is added successfully, the Components.onClassAdded event
	will be fired with one argument - the name of this class.
--]]
function Components.addClass(name: string, module: ModuleScript)
	local class = {
		construct = nil,
		destroy = nil
	}
	
	local initFunction = require(module)
	
	if typeof(initFunction) ~= "function" then
		error("Module for component " .. name .. " must return a function - see docs for help (at " .. module:GetFullName() .. ")")
	end
	
	initFunction(Components, class)
	
	if typeof(class.construct) ~= "function" then
		error("Component " .. name .. " is missing the :construct() function (at " .. module:GetFullName() .. ")")
	end
	
	if typeof(class.destroy) ~= "function" then
		error("Component " .. name .. " is missing the :destroy() function (at " .. module:GetFullName() .. ")")
	end
	
	if Components.classes[name] ~= nil then
		error("Another component is already named " .. name .. " - names must be unique (at " .. module:GetFullName() .. ")")
	end
	
	Components.classes[name] = class
	
	Components.onClassAdded:Fire(name)
end

--[[
	Attempts to remove the component with the given name.
	
	An error will be raised if no component was found with the given name.
	
	Just before the component is removed, the Components.onClassRemoved event
	will be fired with one argument - the name of this class.
--]]
function Components.removeClass(name: string)
	local class = Components.classes[name]
	
	if class == nil then
		error("Remove failed as no component called " .. name .. " was found")
	end
	
	Components.onClassRemoved:Fire(name)
	
	Components.classes[name] = nil
end

--[[
	Convenience function - this finds all descendant module scripts in the given
	instance, and attempts to add them as components.
	
	The name of each component will be taken from the name of the module script.
	
	This function uses Components.addClass() internally - any errors raised by
	that function won't be caught.
--]]
function Components.addClassFolder(folder: Instance)
	for _, descendant in pairs(folder:GetDescendants()) do
		if descendant:IsA "ModuleScript" then
			Components.addClass(descendant.Name, descendant)
		end
	end
end

--[[
	Returns the component for the specific configuration instance given, or 
	creates it if the configuration is not yet associated with a component.
	
	If the configuration instance has no parent, an error will be raised - it's
	expected that configurations are parented to instances to avoid issues
	with destruction detection.
	
	The class will be derived from the name of the configuration - if no class
	is found with that name, an error will be raised.
	
	If the configuration is moved out of the current instance, or if the
	component class is removed, then the component will automatically be
	destroyed.
--]]
function Components.getComponent(config: Configuration)
	if componentConfigs[config] then
		return componentConfigs[config]
	end
	
	if config.Parent == nil then
		error("Can't create a component for a configuration with no parent (at " .. config:GetFullName() .. ")")
	end
	
	local class = Components.classes[config.Name]
	
	if class == nil then
		error("Can't create component named " .. config.Name .. " as no class was found (at " .. config:GetFullName() .. ")")
	end
	
	local object = setmetatable({}, {__index = class})
	object.instance = config.Parent
	object.config = config
	
	object:construct(object.instance)
	
	componentConfigs[config] = object
	
	local unparentConnection
	local classRemoveConnection
	
	unparentConnection = config.AncestryChanged:Connect(function()
		if config.Parent ~= object.instance then
			object:destroy()
			unparentConnection:Disconnect()
			classRemoveConnection:Disconnect()
			componentConfigs[config] = nil
		end
	end)
	
	classRemoveConnection = Components.onClassRemoved.Event:Connect(function(className)
		if className == config.Name then
			object:destroy()
			unparentConnection:Disconnect()
			classRemoveConnection:Disconnect()
			componentConfigs[config] = nil
		end
	end)
	
	return object
end

--[[
	Iterates through the descendants of the given instance, creating components
	for any configurations it finds (that don't already have a component).
	
	This function then listens for new descendants being added - if a
	configuration is added as a descendant later, a component will still be
	created (again, only if a component wasn't already created earlier).
	
	If a new component class is added later, the descendants will be iterated
	over again to check for any components of that class type which would have
	been missed earlier.
	
	To be more specific about how components are detected, whenever this 
	function encounters a Configuration instance whose name matches a
	component name, it'll call Components.getComponent() on that
	configuration instance. All errors are accounted for, so no errors should
	be raised by this function during normal usage.
	
	There's a non-obvious behaviour of this function as implemented currently.
	Due to an odd engine limitation (read: no Destroyed event) this function
	will stop listening to events if the given instance is parented to nil.
	This avoids memory leaks by cleaning up connections after instances are
	destroyed, but means your code should avoid parenting the instance to nil.
	
	Note that components created by this function have their own lifecycle -
	if an instance with a component is moved elsewhere, the component will
	continue to exist. Components are only destroyed when their configuration
	is unparented, which typically happens when the instance is destroyed.
	Furthermore, this means components, unlike the instance passed to this
	function, *can* be safely parented to nil. Keep these notes in mind if you
	manipulate component lifecycles unconventionally.
	
	It's useful to apply this to containers such as ScreenGuis.
	However, be cognizant of possible performance implications of using this on
	large containers - it's not recommended to use on the whole data model, for
	instance.
--]]
function Components.watch(instance: Instance)
	for _, descendant in pairs(instance:GetDescendants()) do
		if descendant:IsA "Configuration"
		and Components.classes[descendant.Name] ~= nil
		then
			Components.getComponent(descendant)
		end
	end
	
	local descendantAddConnection
	local classAddConnection
	local destroyConnection
	
	descendantAddConnection = instance.DescendantAdded:Connect(function(descendant)
		if descendant:IsA "Configuration"
		and Components.classes[descendant.Name] ~= nil
		then
			Components.getComponent(descendant)
		end
	end)
	
	classAddConnection = Components.onClassAdded.Event:Connect(function(className)
		for _, descendant in pairs(instance:GetDescendants()) do
			if descendant:IsA "Configuration"
			and descendant.Name == className
			then
				Components.getComponent(descendant)
			end
		end
	end)
	
	--FUTURE: replace this with Destroyed if/when that's implemented
	destroyConnection = instance.AncestryChanged:Connect(function()
		if instance.Parent == nil then
			descendantAddConnection:Disconnect()
			classAddConnection:Disconnect()
			destroyConnection:Disconnect()
		end
	end)
	
	return descendantAddConnection
end

return Components

Hope this helps! If it did, you might also like my beautifully utilitarian plugins:


Reclass-Convert Reclass ā€¢ Imiji-x32 Imiji ā€¢ Atmos-Pro Atmos ā€¢ Pick-Pro-x32 Pick ā€¢ InCommand-Dark-x32 InCommand


103 Likes

This looks cool! I do have a suggestion for 3 API members that could be added that would reduce a lot of copied code and a lot of need to save and destroy connections. That being an instance function for Heartbeat, Stepped, and RenderStepped. Connections would be made after the construct method is called, and only if the script defined these functions, and would get destroyed when the destroy method is called.

Doing things every frame is very common (so much that it is default API for Unity components).

Your example code is a great example of something that binds to RenderStep and would benefit from just having a class:RenderStepped() function.

Either way, this looks great!

8 Likes

Interesting ideas! Hadnā€™t considered adding those kinds of methods previously :slightly_smiling_face:

Thinking about it, Iā€™m not sure I would implement it into Components directly however. The library is designed to be as simple and minimal as possible, so I donā€™t think itā€™d be in line with the rest of the API.

The way I usually deal with those kinds of situations is by using a Maid library to help manage all the connections and other things that need cleaning. That way I can just add the connections to the maid right away and donā€™t have to worry about the cleanup process. It also means it works in general for all kinds of events rather than just a couple predetermined ones, which can be advantageous if cleaner code is the goal.

Of course, you can always add in the methods yourself if you really want them! I encourage you to explore tweaking Components to your liking to make it work best for you. Iā€™d be interested to see the different versions people come up with :stuck_out_tongue:

(by the way, if you do implement it yourself, be careful with performance - only connect to RenderStepped, Heartbeat etc when the component actually defines a function to handle it!)

2 Likes

What a great module! Reminds me a lot of AGF, or the new Knit.

I have one suggestion,

What if components could have some shared components. (instead of doing Components.classes["somethinkidk"] there should be something like class.shared["somethingidk"]

Also, it took about 5 minutes to understand the concept. I highly recommend that you do something about that.

2 Likes

Ironically I just learned about the Binder coding style about an hour ago from toying with @Quentyā€™s Nevermore Engine, and I just started searching for something like this. Amazing that it exists and was made only hours ago! Iā€™m looking forward to using this.

1 Like

Iā€™ve updated the explanation to be a bit clearer - hope that helps! :slightly_smiling_face:

1 Like

This is a cool module! Iā€™ve been getting into Roact and the principle behind this is very similar to Roact (I believe Roact can also manage non-UI instances). What are the primary differences between this module and Roact?

1 Like

Good question!

Roact is largely built around building UI and managing state; the idea with Roact is that you build a description of some UI in code, and pass that description to Roact (along with some game data). Roact then creates all the instances needed and automatically keeps everything up to date as your game data changes. It enforces a very specific and rigid workflow which is completely different to how UI development has traditionally worked on Roblox.

Components is a lot more flexible and general, but doesnā€™t provide a state management system. This makes Components much more widely applicable - you can use it for more than just creating UI, though it absolutely can just do UI if thatā€™s all you need. However, as your game data changes, youā€™ll have to write the code to change the properties on your instances manually, like you normally do. Unlike Roact, Components tries to augment your existing workflow rather than replacing it entirely.

3 Likes

Cool creation!

How do you intend for this framework to deal with different ā€œthemesā€? Is it the responsibility of each component to handle theming on their own? Do you intend this to be paired with any other libraries to help with this?

Do you have any plans to make dealing with Configuration objects more streamlined? I could imagine a large portion of component code would be dedicated to listening to Changed events & converting ValueInstance objects into built-in Lua types (e.g., representing an array using a Folder of StringValue objects).


I like the idea of using Configuration for this. Being able to use the WYSIWIG editor is definitely a plus; maybe the next step would be to get the code to run in studio so you could truly understand what youā€™re getting.

I also think that to convince people to use your library, youā€™d need to supply a good quantity of common UI components. Buttons, sliders, drop-downs, tabbed views, etc. This will also help give people examples of how to use the library.

1 Like

Thanks! :stuck_out_tongue:

I havenā€™t experimented with this much yet - thankfully thereā€™s plenty of options available since components are just standard OOP objects. One option might be to find the first ancestor with a ā€˜themeā€™ component, then you could use that component to store theme information or theme changed events. Alternatively, you could require a module from elsewhere which stores theme stuffs. Itā€™s really up to you!

I plan to use Components to create my plugin UIs in future, so Iā€™ll be tackling this head on. Iā€™ll make sure to share the method which I find works best.

Iā€™ve thought about it a lot, but havenā€™t settled on a definitive way of doing it yet. Something to keep in mind is that youā€™re not limited to just value instances - you can use anything! You could pass data via BindableEvents or BindableFunctions, or even use a ModuleScript to store a Lua table. Attributes are a potential future solution too. Iā€™m not sure which solution is the best right now, so Iā€™m leaving it as an open question for further exploration similar to theming.

Iā€™ve already been experimenting with this in private - if I get it running well Iā€™ll definitely share it!

Iā€™m going to look into providing a bunch of reference components for sure! It doesnā€™t have to be limited to UI either, since Components is designed to be more general purpose. From the sounds of the replies however, that looks like itā€™ll be a popular use case :slightly_smiling_face:

1 Like

I was really thinking about making a component system exactly like the one you made!
But since you already made one. My honor to use it :slight_smile:
Keep up the good work. Iā€™m a reallly big fan of your work!

I was also thinking of letting developers to have components to execute inside studio too using plugins.
So you can mimic the way roblox UI components works like a custom UIListLayout or something along the lines!

1 Like

Is there a way to run a method for every component? For example, if I created a :setText() method in the class and wanted to set the text to something like ā€œHello, World!ā€ after a button click how would I go about this (similar to state management). Would I need to create a renderstepped event changing the text value from a text value or would there be a simpler way to achieve this?

I was able to do it with a hacky way:

local textBindble = Instance.new("BindableEvent");

return function (Components, class)
	function class:construct(instance)
		textBindble.Event:Connect(function(text)
			instance.Text = text;
		end)
		
		instance.MouseButton1Down:Connect(function()
			self:setText('Testing2')
		end)
	end
	
	function class:setText(text)
		textBindble:Fire(text)
	end
	
	function class:destroy()
		self.conn:Disconnect()
	end
end

It works okay but I would suggest some sort of addition to the API to run a method on all classes. something like class.run(NAME, ā€¦). Not sure on the exact syntax but something built into the API would be amazing.

2 Likes

Thatā€™s a good idea - will look into it :slightly_smiling_face:

1 Like

Iā€™m looking into including a new function in the next release of Components which should address this use case (and perhaps a few others too!)

Hereā€™s the currently planned API:

function Components.getAllComponentsOfType(className: string) -> array<Component>

It returns an array of component objects, which you could iterate over to call a method on all components. Alternatively, you could change properties en masse, or filter the array to only get specific components.

Hereā€™s a quick example component, called TogglePart, using this new function:

return function(Components, class)
	
	local DEBOUNCE_TIME = 1.5
	
	function class:construct()
		self:setToggled(false)
		
		local lastTouchTime = 0
		self.touchConn = self.instance.Touched:Connect(function()
			local now = os.time()
			if now < lastTouchTime + DEBOUNCE_TIME then return end
			lastTouchTime = now
			
			for _, component in pairs(Components.getAllComponentsOfType("TogglePart")) do
				component:setToggled(not component.isToggled)
			end
		end)
	end
	
	function class:destroy()
		self.touchConn:Disconnect()
	end
	
	function class:setToggled(isToggled)
		self.isToggled = isToggled
		
		self.instance.BrickColor = isToggled and BrickColor.Green() or BrickColor.Red()
	end
	
end

export7

3 Likes

Cool module, I like the object oriented design!
Are there any benefits to this compared to using Tags w/ CollectionService?

If Iā€™m understanding it right, class:construct seems similar to GetInstanceAddedSignal and class:destroy seems to be similar to GetInstanceRemovedSignal.

A typo Iā€™m noticing on the wiki page example for GetInstanceAddedSignal is that an onInstanceRemoved function is defined but never called. Youā€™d normally do something like CollectionService:GetInstanceRemovedSignal(InstanceObject):Connect(onInstanceRemovedFunction)

1 Like

Thanks! :stuck_out_tongue:

The primary benefits of using Configuration instaces instead of CollectionService are:

  • you can store things inside the Configuration instances if you want
  • you can easily add and remove Configuration instances in the Explorer, unlike CollectionService tags
  • (in the future), you may be able to add attributes to the Configuration instances, keeping them separate from attributes for other components, unlike a CollectionService based system where all attributes would presumably be added to the same instance

However, depending on how things pan out in the future, Iā€™m not against converting to CollectionService if the situation changes.

Yup, that sounds about right.

Might want to post that to #platform-feedback:developer-hub - I donā€™t have any power over the wiki :slightly_smiling_face:

2 Likes

Thatā€™s amazing! This can unlock so many more possibilities! Youā€™re the best.

1 Like

edit I changed the snippet to add more tabbing, and the video has been updated.
I wrote a Visual Studio Code snippet that can save you some time when making a new component.
It also moves your cursor to the construct function, so you can start typing immediately.

	"Component": {
		"prefix": "component",
		"body": [
			"return function(Components, class)",
			"\t",
			"\tfunction class:construct()",
			"\t\t$0",
			"\tend",
			"\t",
			"\tfunction class:destroy()",
			"\t\t",
			"\tend",
			"\t",
			"end"
		],
		"description": "Component template"
	}
3 Likes

I found out the hard way that apparently class:destroy() wonā€™t run on client-sided components if the instance is destroyed by the server. The cause is that AncestryChanged gets disconnected on the client before it can run. Instance.Destroyed would probably fix this, but it still hasnā€™t been added =(

1 Like

:upside_down_face:

Might have to look into an alternate solution.

1 Like