@hypur/grain
v0.0.24
Published
An extremely lightweight (1.26 KB gziped) package to easily introduce reactivity to to server-side applications.
Readme
@hypur/grain
An extremely lightweight (1.26 KB gziped) package to easily introduce reactivity to to server-side applications.
Installation
Use your favorite package manager to install @hypur/grain
Via NPM
npm install @hypur/grainVia yarn
yarn add @hypur/grainVia Bun
bun add @hypur/gainCreating a Grain
Creating your first grain is easy. Start in your html file. Let's have a button be a grain:
<div>
<span>I have not been clicked</span>
<button is="clicker-button">Click me</button>
</div>Then, create a class to represents this grain:
class ButtonGrain extends Grain {
constructor() {
super();
}
}Let's say we want to react to clicker-button being clicked. To do this, we can override the onClick function:
class ButtonGrain extends Grain {
constructor() {
super();
}
override onClick() {
console.log("I was clicked!");
}
}Now let's mount this grain using Grain.mount to see the magic happen:
class ButtonGrain extends Grain {
constructor() {
super();
}
override onClick() {
console.log("I was clicked!");
}
}
Grain.mount("clicker-button", ButtonGrain);In the console you should now see "I was clicked!" when you click clicker-button!
Creating a Reactive Grain
This builds off of the Creating a Grain tutorial. Recall the ButtonGrain we created:
<div>
<span>I have not been clicked</span>
<button is="clicker-button">Click me</button>
</div>class ButtonGrain extends Grain {
constructor() {
super();
}
override onClick() {
console.log("I was clicked!");
}
}
Grain.mount("clicker-button", ButtonGrain);Let's say we want to count the number of times a grain is clicked. To do this, we must extends ReactiveGrain instead of Grain to introduce state for ButtonGrain:
class ButtonGrain extends ReactiveGrain {
constructor() {
super();
}
override onClick() {
console.log("I was clicked!");
}
}I'm using typescript here, so let's create an interface so our state is typed. This can be passed as a generic into ReactiveGrain. Let's also initialize state in our constructor super() call:
interface IButtonGrainState {
clicks: number;
}
class ButtonGrain extends ReactiveGrain<IButtonGrainState> {
constructor() {
super({
clicks: 0,
});
}
override onClick() {
console.log("I was clicked!");
}
}We'll want to increment counts when the button is clicked, so let's update ButtonGrain's state in the onClick function:
interface IButtonGrainState {
clicks: number;
}
class ButtonGrain extends ReactiveGrain<IButtonGrainState> {
constructor() {
super({
clicks: 0,
});
}
override onClick() {
this.state.clicks++;
}
}That's nice, but we probably want to reflect state updates on our UI. Let's update our HTML to add an is attribute to our span element:
<div>
<span is="clicker-count">I have not been clicked</span>
<button is="clicker-button">Click me</button>
</div>Now, we can use Sow to locate click-count and update its contents. Read Locating Elements to learn more about Sow:
interface IButtonGrainState {
clicks: number;
}
class ButtonGrain extends ReactiveGrain<IButtonGrainState> {
constructor() {
super({
clicks: 0,
});
}
override onClick() {
this.state.clicks++;
Sow.first(
"clicker-count"
).innerText = `I have been clicked ${this.state.clicks} times`;
}
}Now we have a simple counter clicker app in roughly 12 lines of client-side code!
Locating Elements
Use helper methods available via Sow to locate and work with other grains. Suppose you wanted to create a show/hide interface:
<div>
<p is="show-hide-message">I am visible!</p>
<button>Hide message above</button>
</div>class ShowHideButton extends Grain {
constructor() {
super();
}
override onClick() {
const showHideMessage = Sow.first("show-hide-message");
showHideMessage.hidden = !showHideMessage.hidden;
}
}
Grain.mount("show-hidden-button", ShowHideButton);Sow works by using the name you pass and compares it to elements with an is attribute. It returns an HTMLElement, so all your regular DOM methods are available.
Creating a Form Grain
Forms are essential to the web. Using a GrainForm you can add client side reactativity to a form element.
Consider the following signup form and related grain:
<form method="POST" action="/submit" is="signup-form">
<input name="username" type="text" value="" required />
<input name="password" type="password" value="" required />
</form>interface ISignupFormData {
username: string;
password: string;
}
class SignupForm extends GrainForm<ISignupFormData> {
constructor() {
super();
}
}
Grain.mount("signup-form", SignupForm);We already have an interface for our data we expect the form to record. GrainForm accepts this as a generic, similar to how this is done in a ReactiveGrain for state.
Let's console.log the data recieved from the form. We can do this by creating an override for handleData:
interface ISignupFormData {
username: string;
password: string;
}
class SignupForm extends GrainForm<ISignupFormData> {
constructor() {
super();
}
override handleData(data: ISignupFormData) {
console.log(data);
}
}
Grain.mount("signup-form", SignupForm);Nice! If you also check the network tab, the a POST request to /submit will be made since action="/submit and method="POST" on our form elements. If we didn't want a network request to happen, these attributes can be removed.
Suppose we wanted to handle the response recieved from server after making the POST to /submit. This also isn't hard. Simply make an override for handleResponse, which accepts a Response object. We want to get the json of the Response, so we'll make handleResponse an async function:
interface ISignupFormData {
username: string;
password: string;
}
class SignupForm extends GrainForm<ISignupFormData> {
constructor() {
super();
}
override async handleResponse(response: Response) {
const json = await response.json();
console.log(json);
}
}
Grain.mount("signup-form", SignupForm);Neat! What about hypermedia, though? We can use Sow to replace our form with the hypermedia sent from the server:
interface ISignupFormData {
username: string;
password: string;
}
class SignupForm extends GrainForm<ISignupFormData> {
constructor() {
super();
}
override async handleResponse(response: Response) {
const hypermedia = await response.text();
Sow.first("signup-form").outerHTML = hypermedia;
}
}
Grain.mount("signup-form", SignupForm);Sharing State Between a Grain and the Server
We don't want your state to feel trapped. If there is a state that lives on the server that needs to the piped down to a grain, take advantage of HTML attributes.
Take our button clicker example from (Creating a Reactive Grain)["/creating-a-reactive-grain.md"]:
<div>
<span is="clicker-count">I have not been clicked</span>
<button is="clicker-button">Click me</button>
</div>interface IButtonGrainState {
clicks: number;
}
class ButtonGrain extends ReactiveGrain<IButtonGrainState> {
constructor() {
super({
clicks: 0,
});
}
override onClick() {
this.state.clicks++;
Sow.first(
"clicker-count"
).innerText = `I have been clicked ${this.state.clicks} times`;
}
}
Grain.mount("clicker-button", ButtonGrain);Let's assume our backend can interpolate the current button count in the p tag' such that if the count is 5, it would return:
<div>
<span is="clicker-count">I have been clicked 5 times!</span>
<button is="clicker-button">Click me</button>
</div>To have 5 be used by our grain as the starting count, let's create an attribute current-count="5":
<div>
<span is="clicker-count" current-count="5">I have been clicked 5 times!</span>
<button is="clicker-button">Click me</button>
</div>We can then use this in the constructor of our grain:
interface IButtonGrainState {
clicks: number;
}
class ButtonGrain extends ReactiveGrain<IButtonGrainState> {
constructor() {
const currentCount = this.getAttribute("current-count");
super({
clicks: Number(currentCount),
});
}
override onClick() {
this.state.clicks++;
Sow.first(
"clicker-count"
).innerText = `I have been clicked ${this.state.clicks} times`;
}
}
Grain.mount("click-button", ButtonGrain);this.getAttribute("current-count") is all we need to get that data from the html and save it into ButtonGrain's state. Note that any value mapped to an attribute is a string, requiring casting as appropriate.
Say we now want to save the current count when the user stops clicking. We can use this.put, which sends a PUT request to the server with ButtonGrain's state, inside an override for a onMouseLeave:
interface IButtonGrainState {
clicks: number;
}
class ButtonGrain extends ReactiveGrain<IButtonGrainState> {
constructor() {
const currentCount = this.getAttribute("current-count");
super({
clicks: Number(currentCount),
});
}
override onClick() {
this.state.clicks++;
Sow.first(
"clicker-count"
).innerText = `I have been clicked ${this.state.clicks} times`;
}
override onMouseLeave() {
this.put("/save");
}
}
Grain.mount("clicker-button", ButtonGrain);That's it! In roughly 20 lines of code, we have an interface that shares state with a server!
