Document Listeners
Table of contents
Overview
If you haven’t read the Get Started article, we recommend that you do so before continuing with this guide.
The Get Started article introduced the following snippet:
const [city, cityError, cityStatus] = useDocListener("SomeComponent", ["cities", cityId]);
This guide expands on that example by
- Explaining how to use a type-safe version of
useDocListener
. - Describing the optional parameters that
useDocListener
accepts. - Providing guidelines for using the
useDocListener
hook effectively. - Showing how to start a document listener from an event handler.
- Showing how to release claims on individual entities.
Type-safe document listener
If you are using typescript, you can add a template parameter to the useDocListener
hook as shown below.
const [city, cityError, cityStatus] = useDocListener<City>("SomeComponent", ["cities", cityId]);
In this case, the city
variable will be supplied as an object of type City
.
Optional Parameters of useDocListener
The useDocListener
hook accepts a third argument containing optional parameters, so you can invoke it like this:
const [city, cityError, cityStatus] = useDocListener(
"SomeComponent",
["cities", cityId],
{
transform: cityTransform,
onRemoved: handleRemoved,
onError: handleError,
leaseOptions: {
abandonTime: 60000
}
}
);
This example introduces four new parameters:
All of these parameters are optional. We discuss them in detail below.
transform
The transform parameter allows you to change the raw data received from a Firestore document into a different data shape for use in your application.
For example, suppose a city document from Firestore looks like this:
{
cityName: "Liverpool",
councillors: {
UOcUt: {id: "UOcUt", name: "Pat Moloney", ward: "Childwall" },
PxFNV: {id: "PxFNV", name: "Ellie Byrne", ward: "Everton" },
vQwxK: {id: "vQwxK", name: "Lynnie Hinnigan", ward: "Cressington"}
}
}
In this document, councillors are stored in a map indexed by the councillor’s id. Let’s suppose that a document having this structure matches the ServerCity
type.
On the client, it might be more convenient to have the councillors in an array sorted by name as shown below:
{
id: "dIjZC",
cityName: "Liverpool",
councillors: [
{id: "PxFNV", name: "Ellie Byrne", ward: "Everton" },
{id: "vQwxK", name: "Lynnie Hinnigan", ward: "Cressington"},
{id: "UOcUt", name: "Pat Moloney" ward: "Childwall"}
]
}
A document having this structure matches the ClientCity
type.
To convert a ServerCity
into a ClientCity
, we introduce the following transform.
function cityTransform(event: DocChangeEvent<ServerCity>): ClientCity {
const serverData = event.data;
const path = event.path;
const councillors = Object.values(serverData.councillors);
councillors.sort( (a, b) => a.name.localeCompare(b.name) );
return {
id: path[path.length-1],
cityName: serverData.cityName,
councillors
}
}
The transform function is used as shown below.
const [city, cityError, cityStatus] = useDocListener(
"SomeComponent", ["cities", cityId], {transform: cityTransform}
);
In this case, the city
variable will be returned as an object of type ClientCity
.
When a transform
function is given to the useDocListener
hook, you don’t need to supply a template parameter to specify the entity type. The typescript compiler will figure it out from the signature of your transform function.
The transform function is called when the document is initially received from Firestore and whenever the document is modified. The return value from the transform function is stored in the cache (not the raw data from the Firestore document).
onRemoved
The onRemoved
parameter allows you to define a callback function that fires whenever the Firestore document is deleted.
Here’s a snippet showing how to use this parameter.
function handleRemoved(event: DocRemovedEvent<ServerCity>) {
const serverData = event.data;
const message = `The city "${serverData.cityName}" has been deleted`;
console.log(message);
}
const [city, cityError, cityStatus] = useDocListener<City>(
"SomeComponent", ["cities", cityId], {onRemoved: handleRemoved}
);
The handleRemoved
function defined here merely logs a message to the console. In a production app, you might consider rendering an “info” message that notifies the user that the city entity was deleted.
See Manage client-side state for an example that shows how you can implement an alerting system.
onError
The onError
parameter allows you to define a callback function that fires if an error occurred while fetching the document from Firestore.
Here’s a snippet showing how to use this parameter.
function handleError(event: DocErrorEvent) {
const path = event.path;
const cityId = path[path.length-1];
const message = `An error occurred while loading the city[id=${cityId}]`;
console.error({message, event.error});
}
const [city, cityError, cityStatus] = useDocListener<City>(
"SomeComponent", ["cities", cityId], {onError: handleError}
);
The onError
function defined here merely logs to the console. In a production app, you really should render an error message that the user will notice.
Again, see Manage client-side state for details about implementing an alerting system.
leaseOptions
The leaseOptions
parameter is an object that customizes the behavior of the lease created for the entity. Currently, there is only one possible field within the leaseOptions
namely abandonTime
. This value specifies the number of milliseconds that the entity will remain in the cache after all components have released their claims on that entity.
Here’s a snippet showing how to use the leaseOptions
parameter.
const [city, cityError, cityStatus] = useDocListener(
"SomeComponent", ["cities", cityId], {leaseOptions: {abandonTime: 60000}}
);
If you want the entity to remain in the cache indefinitely, set abandonTime
equal to Number.POSITIVE_INFINITY
.
Guidelines for using the useDocListener
hook
There are several things you should know about the useDocListener
hook.
- The document listener is started the first time the hook is called for a given path.
- The optional parameters are only used when the document listener is started. This means that the optional parameters are ignored after the first call to the hook for a given path.
- All components that invoke
useDocListener
will establish a claim on the specified entity. - Entities become eligible for garbage collection once all claims have been released.
- If the document specified by the
path
does not exist or cannot be read due to Firestore secuity rules, thenuseDocListener
will return an error whose message contains the string “Missing or insufficient permissions”.
It is important to follow the guidelines listed below:
- If multiple clients invoke
useDocListener
, make sure they all use the sametransform
. This is important because the cache will store only one instance of a given entity. If different components use different transforms you will end up with type conflicts. - Similar comments apply to the other optional parameters (
onRemoved
,onError
andleaseOptions
). Make sure that all invocations ofuseDocListener
for a given path use the same handlers for these parameters.
Using a document listener within event handlers
The watchEntity
function gives you the same functionality as the useDocListener
hook, but it is designed for use within event handlers. There are two types of event handlers to consider.
First, we have Firestore event handlers. These are handlers that fire when a Firestore document changes state, and they are specified as the optional parameters of the useDocListener
hook:
- transform fires when the document is first loaded or is modified
- onRemoved fires when the document is removed from Firestore
- onError fires if an error occurs while fetching the document from Firestore
Second, we have HTML event handlers such as the onClick
handler for a button.
We illustrate the usage patterns for both types of event handlers below.
Using watchEntity
within a Firestore event handler
Suppose that city documents in Firestore match the type ServerCity
, and they look like this:
// Document: cities/dIjZC
{
cityName: "Liverpool",
councillors: {
UOcUt: true,
PxFNV: true,
vQwxK: true
}
}
In this scenario, the set of city councillors is represented as a map where the key is the id
of the councillor, and the value is the boolean literal true
. Information about individual councillors can be found in the councillors
collection which stores documents of type ServerCouncillor
, defined as follows:
interface ServerCouncillor {
cityId: string;
name: string;
ward: string;
}
Here’s an example of a councillor document stored in Firestore:
// Document: councillors/PxFNV
{
cityId: "dIjZC",
name: "Ellie Byrne",
ward: "Everton"
}
We require a ClientCouncillor
type that extends ServerCouncillor
by adding the councillor id
. Thus, we have:
interface ClientCouncillor extends ServerConcillor {
id: string
}
Here’s an example of the city data that will be stored in the local cache:
{
id: "dIjZC",
cityName: "Liverpool",
councillors: [
{id: "PxFNV", name: "Ellie Byrne", ward: "Everton" , cityId: "dIjZC"},
{id: "vQwxK", name: "Lynnie Hinnigan", ward: "Cressington", cityId: "dIJZC"},
{id: "UOcUt", name: "Pat Moloney" ward: "Childwall" , cityId: "dIJZC"}
]
}
We can use the following code to satisfy the requirements of the current scenario.
/**
* Sort an array of concillors by name
*/
function sortCouncillors(councillors: ClientCouncillor[]) {
councillors.sort( (a, b) => a.name.localeCompare(b.name));
}
function councillorTransform(event: DocChangeEvent<ServerCouncillor>) {
const serverData = event.data;
const path = event.path;
const id = path[path.length-1];
const councillor: ClientCouncillor = {
...serverData,
id
}
const cityPath = ["cities", councillor.cityId];
const [city] = getEntity<ClientCity>(api, cityPath);
if (city) {
const councillors = city.councillors.filter( c => c !== id);
councillors.push(councillor);
sortCouncillors(councillors);
setEntity(api, cityPath, {...city, councillors});
}
return councillor;
}
function cityTransform(event: DocChangeEvent<ServerCity>) : ClientCity {
const api = event.api; // The EntityApi instance used by the application
const serverData = event.data;
const leasee = event.leasee;
const path = event.path;
const councillors: ClientCouncillor[] = [];
for (const councillorId in serverData.councillors) {
const councillorPath = ["councillors", councillorId];
const [councillor, error] = watchEntity(
api, leasee, councillorPath, {transform: councillorTransform}
)
if (error) {
console.error(`Failed to load councillor[id=${councillorId}]`, error);
}
if (councillor) {
councillors.push(councillor);
}
}
sortCouncillors(councillors);
return {
id: path[path.length-1],
name: serverData.name,
councillors
}
}
The watchEntity
function accepts the same optional parameters as useDocListener
.
Using watchEntity
within an HTML event handler
In this example, a component renders the name of a given city when a button is pressed. Yes, it’s a somewhat contrived example.
import {
useEntityApi, useEntity, watchEntity, useReleaseAllClaims
} from "@gmcfall/react-firebase-state";
/**
* A React Component that renders the city name when a button is pressed
*/
function CityName({cityId}) {
const path = ["cities", cityId];
const api = useEntityApi();
const [city,,cityStatus] = useEntity<City>(path);
useReleaseAllClaims("CityName");
function handleClick() {
watchEntity(api, "CityName", path);
}
const text = (
cityStatus==="pending" ? "Loading..." :
cityStatus==="error" ? "Oops! An error occurred." :
cityStatus==="deleted" ? "Oops! The city was deleted from the database" :
city ? city.name :
""
)
return (
<>
<button onClick={handleClick}>
Show the city name
</button>
<span>{text}</span>
</>
)
}