-
Notifications
You must be signed in to change notification settings - Fork 4
Context
- TLDR;
- Introduction
- The Expression type
- Local Contexts
- Implicit Contexts
- Navigation Contexts
- The Global Context
- Reading from a Context
- Writing to a Context
- Advanced topics
- Keep reading
Contexts are the Beagle way of creating and manipulating a state that can shared among the components. They can appear in four different flavours. See the examples below:
const username = createContext<string>('username', 'John')
export const MyScreen: Screen = () => <Container context={username}>{username.get('name')}</Container>
export const MyScreen: Screen = () => <TextInput onChange={value => alert(value)} />
interface MyScreenRequest extends ScreenRequest {
navigationContext: {
address: Address,
}
}
export const MyScreen: Screen<MyScreenRequest> = ({ navigationContext }) => (
<>{navigationContext.get('address').get('street')}</>
)
interface GlobalContext {
username: string,
}
const globalContext = getGlobalContext<GlobalContext>()
export const MyScreen: Screen = ({ navigationContext }) => <>{globalContext.get('username')}</>
To read from Contexts use the methods get
(objects) and at
(arrays). To write to Contexts, use the method set
.
Beagle has no script language embedded in it, and we defend that it shouldn't have one. But we still need to make it possible to create dynamic UIs from the backend. To solve this issue, Beagle introduces three concepts: Contexts, Operations and Actions. This topic talks about Contexts.
Contexts are the Beagle way of creating and manipulating a state that can shared among the components. We could make an equivalence to variables in programming languages, they can be declared (local contexts), some are global (global context) and others are injected into your code (navigation context and implicit contexts).
Contexts are variables of Beagle, they're not variables of your backend application, i.e. Beagle Contexts are resolved when the code is run by the frontend, and not when the request is made to the server.
NodeJS will read your screen code and serialize it as a JSON. Beagle Contexts are serialized in a special syntax called
Beagle expressions. Example: "@{user.name}"
.
The Beagle Contexts we create in the backend application, will be serialized into Beagle Expressions (strings), which will only be resolved by the frontend application.
It is very important to know this so you can understand something like user.get('age') + 5
or
user.get('documents').length
would never work. Considering user
is a Beagle Context, we can't possibly know its
value until the frontend runs it.
In any case, both of the examples above wouldn't compile, since they would result in type errors. user
is of type
ContextNode
. Every Context is a ContextNode
. A ContextNode
holds a reference to a variable that will only know
its value when executed by the frontend application.
To make operations over Beagle Contexts, instead using javascript operators, you should use Beagle Operations, which also translates to Beagle Expressions (strings) when serialized, i.e. they're also resolved when run by the frontend application.
In Beagle Backend TS, we have a special type called Expression<T>
. When a variable is of type Expression<T>
, it means
it accepts the type T
, a Context referencing a variable of type T
or an Operation that results in T
.
In many cases instead of using T
for an attribute of your component or action, you'll
actually want to use Expression<T>
, which just means that it accepts anything that will end up as the type T
in the
frontend. See the example below for the custom component Card
, first introduced
here.
import { BeagleJSX, FC, Expression, WithChildren } from '@zup-it/beagle-backend-core'
import { WithStyle, StyledComponent } from '@zup-it/beagle-backend-components'
interface Props extends WithChildren, WithStyle {
title?: Expression<string>,
elevation: Expression<number>,
coverArt?: Expression<string>,
}
export const Card: FC<Props> = ({ id, children, style, ...properties }) => (
<StyledComponent name="card" id={id} style={style} properties={properties}>
{children}
</StyledComponent>
)
Now the component Card
also accepts Beagle expressions instead of only strings or numbers.
Local contexts are generally used for holding values that are accessed by multiple components, like a Form. It allows
interaction between components. It is also useful for holding responses from lazily loaded data. For instance, one could
use the action sendRequest
(Making requests) to load a list of products and use a local context to store them, so they can be
displayed by a ListView.
A local context can be created via the function createContext
. This function accepts 2 arguments
- The first is a string with a name to the context. This name is useful for identifying which context is which in the
frontend and making it possible for the frontend to give useful errors messages like:
Can't find context with name "person"
. - The initial value the frontend application should give to the context. This is optional and when not provided, the context will be initialized with null.
createContext
is a generic function, i.e., it can receive a type T
and act differently (type-wise) according to it.
Let's say we want to create a context that holds a string:
import { createContext } from '@zup-it/beagle-backend-core'
const username = createContext<string>('username')
Simple right? username
is now a Beagle context that refers to a string value. You can pass any type you want in the
generic, but for simple types like this, when you provide the second argument (initialization), you can omit the
generic, since it can be easily inferred by the language.
Notice the type is string
and not Expression<string>
. You shouldn't use the type Expression
when typing
contexts, It is implicit that any Beagle Context accepts expressions.
import { createContext } from '@zup-it/beagle-backend-core'
const username = createContext('username', 'John')
Let's see a more complex example of a context:
interface Document {
name: string,
value: string,
image?: string,
}
interface User {
id: string,
name: string,
lastName: string,
age: number,
documents: Document[],
}
const user = createContext<User>('user')
It is extremely important to specify the generic type whenever the second argument is not provided. In the code above,
we created a context that holds a User
.
Local contexts are the only type of contexts that need to be declared. As their name says they're local, so we must know: "local for what?".
To answer this question, we declare the context we created for a component, using the attribute context
. When a
context is declared for a component, this context will be accessible by this component and any of its descendants. In
other words, the scope of a local context is the component where it's declared plus its descendants. See the example
below:
import { BeagleJSX, createContext } from '@zup-it/beagle-backend-core'
import { Container } from '@zup-it/beagle-backend-components'
import { Screen } from '@zup-it/beagle-backend-express'
// (...) consider the context user declared in the previous example
const MyScreen: Screen = () => (
<Container context={user}>
<>Welcome {user.get('name')} {user.get('lastName')}!</>
</>
)
In the code above we declared user
to the root component, i.e. it will be accessible by every component in the screen.
After declaring the context, we printed the user name using the context api.
If a local context is not declared, when running the code in the frontend, Beagle will be unable to find it, producing
log messages like Couldn't find context of name "user"
.
The only difference from local contexts is that implicit contexts don't need to be created or declared, they're still local, but they're implicit to the code.
In practicality, implicit contexts are contexts that are injected by components. They are injected through functions and their scopes are the component where they appear plus its descendants.
Take a Text Input, for instance, it has the property onChange
. When programming an on change event, we generally need
to know what changed. The new text value is an implicit context! We didn't declare it, but it does exist. We receive
this context through a function, see the example below:
import { BeagleJSX, createContext } from '@zup-it/beagle-backend-core'
import { alert } from '@zup-it/beagle-backend-core/actions'
import { TextInput } from '@zup-it/beagle-backend-components'
import { Screen } from '@zup-it/beagle-backend-express'
const MyScreen: Screen = () => <TextInput onChange={value => alert(value)} />
The example above is very simple and it states that every time the value of the input changes, an alert dialog will appear in the screen with the new value typed.
alert
is an action. To know more about actions, check this article.
Other components that provides implicit contexts are:
- PageView:
<PageView onPageChange={newPageIndex => alert(`The new page is: ${newPageIndex}`)}>
- TabBar:
<TabBar
items={[{ title: 'tab 1' }, { title: 'tab 2' }]}
onTabSelection={newTabIndex => alert(`The new tab is: ${newTabIndex}`)}
/>
- ListView (GridView is similar)
<ListView dataSource={[1, 2, 4]}>
{item => <Template><>{item}</></Template>} {/* item is an implicit context referring to the current iteration */}
</ListView>
Actions are better explained here. But this works very similarly to components. Some actions will inject implicit contexts, a good example is the action to send a request.
The sendRequest
action has the events onSuccess
and onError
. To program the onSuccess
event, we need to know
what was the response from the server. To program the onError
event, we need to know what the error was. For this
reason, both of them create implicit contexts, see the example below:
import { alert, sendRequest } from '@zup-it/beagle-backend-core/actions'
import { Product } from '../model/Product' // detailing this interface is not important for this example
const loadProducts = sendRequest<Product[]>({
// ...
onSuccess: response => alert(`Products loaded: ${response.data.at(0).get('name')}`)
})
In the code above we use the sendRequest
action to load a list of products and we receive an implicit context in
the onSuccess
event. This implicit context contains the response returned by the server. We then open an alert dialog
box telling the name of the first product in the list.
If you need to create your own components or actions that inject implicit contexts themselves, please read the article Advanced topics: Context.
The scope of local and implicit contexts are very limited and won't survive navigations. The next larger scoped contexts are the navigation contexts, plural because there will be one navigation context for each screen of the application. A navigation context is created when the screen is first rendered and destroyed only when it leaves the navigation stack of the application.
The navigation contexts are useful for passing data between pages. Let's say we have a multi-screen form and we don't want to send the data to the server before we fill them all. In this case, the first form can pass its data to the second form, which can send all the data to the server at once.
Another use case is for detailing some item in a list. Let's say we have a list of products and the backend already brought all the data we need to create a "details" page for the product. Instead of asking the backend again for the same data, we can send the product selected to the next screen to display.
The possibilities are endless! When finishing an order, we could check if the user already set up the address, if not, we can start a "fill address flow", making the last page of the flow send the user back to the order page, but with an address in the navigation context.
As stated previously, each screen has its own navigation context, and as mentioned in the topic Giving a type to your screens we can tell Beagle what kind of navigation context is expected by a screen. See the example below:
import { BeagleJSX } from '@zup-it/beagle-backend-core'
import { Container } from '@zup-it/beagle-backend-components'
import { Screen } from '@zup-it/beagle-backend-express'
import { Product } from '../model/Product' // detailing this interface is not important for this example
interface ProductDetailsRequest extends ScreenRequest {
navigationContext: {
product: Product,
}
}
export const ProductDetailsScreen: Screen<ProductDetailsRequest> = ({ navigationContext }) => (
<>Product name: {navigationContext.get('product').get('name')}</>
)
The navigation context is always injected into the screen, there's no need to create or declare it. If the screen
accepts a navigation context, it must declare it in the interface provided to the type Screen
. In this case, having
the product in the navigation context is required to build this page, for this reason, both navigationContext
and
product
are required in ProductDetailsRequest
. If for some reason, they weren't, you could declare them as optional
(?
).
When navigating to ProductDetailsScreen
, Typescript will make sure a navigation context with a product is provided.
The code won't compile otherwise.
Sometimes we need to store values at the application level, sharing them between multiple screens and even with the native code. Local contexts, implicit contexts or even navigation contexts can't do this, their scope are limited and they're not visible by the entire application.
The global context can be read and set from any screen or native code. This is useful for manipulating global data structures like a shopping cart or the current session.
To get a reference to the Global Context, we must use the function getGlobalContext
. It is extremely important to type
this structure, and because of this, we recommend creating a single separated file for the Global Context of your
application. See the example below:
global-context.ts
:
import { getGlobalContext } from '@zup-it/beagle-backend-core'
import { Product } from '../model/product' // detailing this interface is not important for this example
import { User } from '../model/user' // detailing this interface is not important for this example
export interface GlobalContext {
cart: Product[],
user: User,
}
export const globalContext = getGlobalContext<GlobalContext>()
my-screen.ts
:
import { BeagleJSX } from '@zup-it/beagle-backend-core'
import { Screen } from '@zup-it/beagle-backend-express'
import { globalContext } from '../global-context'
export const MyScreen: Screen = () => <>Hello {globalContext.get('user').get('name')}!</>
The Global Context is the only type of Beagle Context that can be both read and written outside Beagle. To learn how to do this in your frontend application, check the documentation for the Global Context for the platform you're working with (Android, iOS, Web or Flutter).
To read from a Beagle Context you should use the methods get
and at
.
-
get
access properties of a context that refers to an object; -
at
access indexes of a context that refers to an array.
See the example below:
import { BeagleJSX, createContext } from '@zup-it/beagle-backend-core'
import { Container } from '@zup-it/beagle-backend-components'
import { Screen } from '@zup-it/beagle-backend-express'
interface Person {
name: string,
age: number,
nicknames: string[],
}
const person = createContext<Person>('person', {
name: 'John',
age: 30,
nicknames: ['J', 'Dude', 'Guy']
})
export const MyScreen = () => (
<Container context={person}>
<>
Hello {person.get('name')}! You're {person.get('age')} years old and also attends by the names
{person.get('nicknames').at(0)}, {person.get('nicknames').at(1)} and {person.get('nicknames').at(2)}.
</>
</Container>
)
Above, we use get
when the context is an object and at
when it's an array. But don't worry too much, TS will take
care of this for us, it will only accept get
when it's an object, only at
when it's an array and when it's a
primitive type, it won't accept neither get
nor at
.
Tip: normally, to display the contents of an array, instead of accessing its indexes, we want to iterate over all of its items. To do so, you'd need a ListView or a GridView. To format an array into a string, you'd need a custom operation.
To modify the content of a Context, we need an Action. To create this action, we can call the method set
of the context. See the example below:
import { BeagleJSX, createContext } from '@zup-it/beagle-backend-core'
import { sum } from '@zup-it/beagle-backend-core/operations'
import { Button } from '@zup-it/beagle-backend-components'
import { Screen } from '@zup-it/beagle-backend-express'
const counter = createContext('counter', 0)
export const MyScreen: Screen = <Button onPress={counter.set(sum(counter, 1))}>value: {counter}</Button>
Above, we created a screen with a single button where the label increments by one every time it's clicked. context.set
will only accept values of its type or Expressions of it. sum
is one of the Operations shipped with
Beagle.
Global Contexts can be set outside Beagle by the frontend application. To know more check the documentation for the frontend lib you're using.
This article gave you the basic knowledge to understand and use Beagle Contexts. To go a step further and learn things like injecting implicit contexts for your own components and actions, please read "Context" in the "Advanced topics" of this documentation.
Next topic: Operations