Contexts
Sometimes you need to initialize some kind of context before using a component. For instance, when working with forms in Phoenix templates, you usually need to define a form variable that can be passed to form elements along with each field name. Here's an example:
{ "email": null, "name": "John Doe" }
Using Surface contexts, you can improve the developer experience by not forcing one to pass the form and field values multiple times. Instead, you can store those values in the context and retrieve them in the child component when needed.
Note: Although storing values in contexts might be an interesting way to avoid "Property drilling", you must use them carefully. Overusing them will make your code less explicit, which may lead to components that are harder to reason about.
Here's the updated version of our form now using components and contexts:
{ "email": null, "name": "John Doe" }
Using <Context>
You can put
or get
values to/from the context using the Context
component.
Putting values into the context
Let's take a look at our Form
component.
defmodule Form do
use Surface.Component
import Phoenix.HTML.Form
alias Surface.Components.Raw
prop for, :any, required: true
prop change, :event
prop autocomplete, :string, values: ["on", "off"]
slot default
def render(assigns) do
~F"""
{form =
form_for(@for, "#",
phx_change: assigns.change.name,
autocomplete: assigns.autocomplete
)}
<Context put={form: form}>
<#slot />
</Context>
<#Raw></form></#Raw>
"""
end
end
The value of variable form
will be stored in the context under the key :form
and will be
available to any child component inside <Context>...</Context>
, including any instance present
in the content assigned to the "default" slot.
We can use the same concept in our Field
component and add the field name to the context too:
defmodule Field do
use Surface.Component
import Phoenix.HTML.{Form, Tag}
@doc "The field name"
prop name, :string, required: true
slot default
def render(assigns) do
~F"""
<div class="field">
<Context get={form: form}>
{label(form, @name, class: "label")}
<div class="control">
<Context put={field: String.to_atom(@name)}>
<#slot />
</Context>
{error_tag(form, @name)}
</div>
</Context>
</div>
"""
end
defp error_tag(form, field) do
Enum.map(Keyword.get_values(form.errors, String.to_atom(field)), fn {error, _} ->
content_tag(:p, error, class: "help is-danger")
end)
end
end
Retrieving values from the context
Now that we have both values, form
and field
properly stored in the context,
the TextInput
component can access those values and use them as need:
defmodule TextInput do
use Surface.Component
import Phoenix.HTML.Form
prop placeholder, :string
def render(assigns) do
~F"""
<Context get={form: form, field: field}>
{text_input(form, field,
class: css_class(["input", isDanger: Keyword.has_key?(form.errors, field)]),
placeholder: @placeholder
)}
</Context>
"""
end
end
Scoping context values
One important thing to keep in mind it that storing values from different components might lead to naming conflicts. To avoid that, Surface allows you to "namespace" the values stored using an extra scope key.
The key is just an atom
and can usually be the component's module. For instance:
Instead of:
<Context put={form: form}>
you can use:
<Context put={__MODULE__, form: form}>
That would create a composite key containing both atoms, i.e. {Form, :form}
for that value.
Now, whenever you need to retrieve the value, you must pass the scope too:
<Context get={Form, form: f}>
In case you need to get/put values from/to different scopes, you can define
multiple get
/put
props. For instance:
<Context
get={Form, form: form}
get={Field, field: field}>
...
</Context>
Note: If you want to distribute a library that store values into the context, it's highly recommended that you always scope those values as demonstrated. This way you make sure it can play nicely with other libraries that also use contexts.