Home >Web Front-end >JS Tutorial >Server Actions have been fixed
Server Actions emerged as an idea to reduce client code and simplifying the interactions that require communication with the server. It is an excellent solution that allows developers to write less code. However, there are several challenges associated with its implementation in other frameworks, which should not be overlooked.
In this article, we will talk about these problems and how in Brisa we have found a solution.
To understand what Server Actions provide, it is useful to review how communication with the server used to be. You are probably used to performing the following actions for each interaction with the server:
These seven actions are repeated for each interaction. For example, if you have a page with 10 different interactions, you will repeat a very similar code 10 times, changing only details such as the type of request, the URL, the data sent and the status of the customer.
A familiar example would be
a:
<input onInput={(e) => { // debounce if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { fetch("/api/search", { method: "POST", body: JSON.stringify({ query: e.target.value }), }) .then((res) => res.json()) .then((data) => { setState({ data }); }); }, 300); }} />
And in the server:
app.post("/api/search", async (req, res) => { const { query } = req.body; const data = await search(query); res.json(data); });
Increasing the client bundle size... and the frustration of developers.
Server Actions encapsulate these actions in a Remote Procedure Call (RPC), which manages the client-server communication, reducing the code on the client and centralizing the logic on the server:
Here everything is done for you by the Brisa RPC.
This would be the code from a server component:
<input onInput={(e) => { // debounce if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { fetch("/api/search", { method: "POST", body: JSON.stringify({ query: e.target.value }), }) .then((res) => res.json()) .then((data) => { setState({ data }); }); }, 300); }} />
Here, developers do not write client code, since it is a server component. The onInput event is received after the debounce, handled by the Client RPC, while the Server RPC uses "Action Signals" to trigger the Web Components that have signals registered with that store property.
As you can see, this significantly reduces the server code and, best of all, the code size on the client does not increase with each interaction. The RPC Client code occupies a fixed 2 KB, whether you have 10 or 1000 such interactions. This means that increase 0 bytes in the client bundle size, with other words, doesn't increase.
Moreover, in the case of needing a rerender, this is done on the server and is returned in HTML streaming, making the user see the changes much earlier than in the traditional way where you had to do this work on the client after the server response.
In this way:
In other frameworks such as React, they have focused on actions only being part of the form onSubmit, instead of any event.
This is a problem, since there are many non-form events that should also be handled from a server component without adding client code. For example, an onInput of an input to do automatic suggestions, an onScroll to load an infinite scroll, an onMouseOver to do a hover, etc.
Many frameworks have also seen the HTMX library as a very different alternative to server actions, when in fact it has brought very good ideas that can be combined with Server Actions to have more potential by simply adding extra attributes in the HTML that the RPC Client can take into account, such as the debounceInput that we have seen before. Also other HTMX ideas like the indicator to show a spinner while making the request, or being able to handle an error in the RPC Client.
When Server Actions were introduced in React, there was a new paradigm shift that many developers had to change the mental chip when working with them.
We wanted to make it as familiar as possible to the Web Platform, this way, you can capture the serialized event from the server and use its properties. The only event a little different is the onSubmit that has already transferred the FormData and has the e.formData property, nevertheless, the rest of event properties are interactable. This is an example resetting a form:
<input onInput={(e) => { // debounce if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { fetch("/api/search", { method: "POST", body: JSON.stringify({ query: e.target.value }), }) .then((res) => res.json()) .then((data) => { setState({ data }); }); }, 300); }} />
In this example, there is no client code at all and during the server action you can disable the submit button with the indicator, using CSS, so that the form cannot be submitted twice, and at the same time after doing the action on the server and access the form data with e.formData and then resetting the form using the same API of the event.
Mentally, it is very similar to working with the Web Platform. The only difference is that all the events of all the server components are server actions.
This way, there is a real separation of concerns, where it is NOT necessary to put "user server" or "use client" in your components anymore.
Just keep in mind that everything runs only on the server. The only exception is for the src/web-components folder which runs on the client and there the events are normal.
In Brisa, the Server Actions are propagated between Server Components as if they were DOM events. That is to say, from a Server Action you can call an event of a prop of a Server Component and then the Server Action of the parent Server Component is executed, etc.
<input onInput={(e) => { // debounce if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { fetch("/api/search", { method: "POST", body: JSON.stringify({ query: e.target.value }), }) .then((res) => res.json()) .then((data) => { setState({ data }); }); }, 300); }} />
In this case, the onAfterMyAction event is executed on the parent component and an action can be done on the server. This is very useful to make actions on the server that effect several server components.
Especially after the last few weeks Web Components have been a bit frowned upon after several discussions on X (formelly Twitter). However, being part of the HTML, it is the best way to interact with Server Actions for several reasons:
Using attributes in Web Components requires serialization in the same way as transmitting data from server to client without using Web Components, therefore, using both, there is no extra serialization to manage.
Note: Streaming HTML and processing it with the diffing algorithm is something I explained in this other article if you are interested.
In Brisa, we have added a new concept to give even more power to the Server Actions, this concept is called "Action Signals". The idea of the "Action Signals" is that you have 2 stores, one on the server and one on the client.
Why 2 stores?
The default server store lives only at the request level. And you can share data that will not be visible to the client. For example you can have the middleware set the user and have access to sensitive user data in any Server Component. By living at request level it is impossible to have conflicts between different requests, since each request has its own store and is NOT stored in any database, when the request is finished, it dies by default.
On the other hand, in the client store, it is a store that each property when consumed is a signal, that is to say, if it is updated, the Web Component that was listening to that signal reacts.
However, the new concept of "Action Signal" is that we can extend the life of the server store beyond the request. To do this it is necessary to use this code:
<input onInput={(e) => { // debounce if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { fetch("/api/search", { method: "POST", body: JSON.stringify({ query: e.target.value }), }) .then((res) => res.json()) .then((data) => { setState({ data }); }); }, 300); }} />
This transferToClient method, share server data to the client store and converted into signals. In this way, many times it will not be necessary to make any re-rendering from the server, you can simply from a Server Action make react the signals of the Web Components that were listening to that signal.
This store transfer makes the life of the server store now:
Render initial Server Component → Client → Server Action → Client → Server Action...
So it goes from living from only at request level to live permanently, compatible with navigation between pages.
Example:
app.post("/api/search", async (req, res) => { const { query } = req.body; const data = await search(query); res.json(data); });
In this example, we extend the life of the errors store property, not to be used on the client, but to be reused in the Server Action and then finally in the rerender of the Server Action. In this case, being a non-sensitive data, it is not necessary to encrypt it. This example code all happens on the server, even the rerendering and the user will see the errors after this rendering on the server where the Server RPC will send the HTML chunks in streaming and the Client RPC will process it to make the diffing and show the errors to give feedback to the user.
If within a server action some variable is used that existed at render level, at security level many frameworks like Next.js 14 what they do is to encrypt this data to create an snapshot of data used at the time of rendering. This is more or less fine, but encrypting data always has an associated computational cost and it is not always sensitive data.
In Brisa, to solve this, there are different requests, where in the initial render it has a value, and in the server action you can capture the value that it has in this request.
<input debounceInput={300} onInput={async (e) => { // All this code only runs on the server const data = await search(e.target.value); store.set("query", data); store.transferToClient(["query"]); }} />
This is useful in some cases but not always, for example if you do a Math.random it will be different between the initial render and the Server Action execution for sure.
<input onInput={(e) => { // debounce if (timeout) { clearTimeout(timeout); } timeout = setTimeout(() => { fetch("/api/search", { method: "POST", body: JSON.stringify({ query: e.target.value }), }) .then((res) => res.json()) .then((data) => { setState({ data }); }); }, 300); }} />
This is why we created the concept of "Action Signals", to transfer data from the server store to the client store, and the developer can decide whether to encrypt it or not at will.
Sometimes, instead of querying the database from the Server Action, you may want to transfer data that already exists in the initial render even if it requires an associated encryption. To do this, you simply use:
app.post("/api/search", async (req, res) => { const { query } = req.body; const data = await search(query); res.json(data); });
When you do:
<input debounceInput={300} onInput={async (e) => { // All this code only runs on the server const data = await search(e.target.value); store.set("query", data); store.transferToClient(["query"]); }} />
Inside a Web Component (client) will always be encrypted, but on the server it will always be decrypted.
Note: Brisa uses aes-256-cbc for encryption, a combination of cryptographic algorithms used to securely encrypt information recommended by OpenSSL. Encryption keys are generated during the build of your project.
In Brisa, although we like to support writing Web Components easily, the goal is to be able to make a SPA without client code and only use Web Components when it is a purely client interaction or the Web API has to be touched. That's why Server Actions are so important, as they allow interactions with the server without having to write client code.
We encourage you to try Brisa, you just have to run this command in the terminal: bun create brisa, or try some example to see how it works.
The above is the detailed content of Server Actions have been fixed. For more information, please follow other related articles on the PHP Chinese website!