- Creating a Store
- Defining Schema
- CRUD the Store
- Searching the Store
- Event Handling
- Managing App state
- Helpers
Think about a store as a document or table in a database. CWS provides a single way to create a store with ClientStore class.
import {ClientStore, Schema} from "client-web-storage";
interface ToDo {
name: string;
description: string;
complete: boolean;
}
// create a store providing the name and schema object
// with default values or javasctipt types
const todoStore = new ClientStore<ToDo>("todo", {
$name: String,
description: "No Description",
complete: false
});
The ClientStore class takes 3 arguments:
- Required name of the store;
- Required data schema;
- Optional store configuration;
The ClientStore allows you to define the store (table or document) as well as the app (database) it belongs to.
An app can have multiple stores just like a database can have multiple documents or tables. The web application you create may also have multiple sub-apps. For example, you can have a web application which inside have a chat app, a widgets app and the rest is your application.
CWS allows you to split these nicely to avoid having to mix data or deal with data from a different context. It does this by allowing you to control the store instance and store app.
A store instance is equivalent to a document or table in a database. The ClientStore returns an instance to the store if it already exists. For example, if you try to create 2 stores with the same name and type, they will point to the same document/table but may have different schema.
const todoStore1 = new ClientStore("todo", {
$name: String
})
const todoStore2 = new ClientStore("todo", {
$name: String,
description: "No Description"
})
// only add item to one of them
await todoStore2.createItem({
name: "sample"
})
todoStore1 === todoStore2 // FALSE
await todoStore1.size() // 1
await todoStore2.size() // 1
As you can see above, the store instances are different and both stores have different schema, but they point to the same table or document in the database. The storage type must be the same for this behavior.
This behavior is similar to document NoSQL databases like MongoDB and DynamoDB. Here you always define a schema for validation and data integrity but still can have items with different schema stored together.
If you want strict schema tables, all you need to do is ensure all stores have unique names.
CWS gives you the flexibility to follow a strict SQL and NoSQL database easily without having to change interface. You can take a look at the store types for even more granular control.
By default, all stores are part of the same app/database called App
.
You can create stores with same name in different apps (database) by specifying the appName
in the configuration as
the third argument to the ClientStore.
const todoStore1 = new ClientStore("todo", {
$name: String
}, {
appName: "App1"
});
const todoStore2 = new ClientStore("todo", {
$name: String,
description: "No Description"
}, {
appName: "App2"
});
// only add item to one of them
await todoStore2.createItem({
name: "sample"
})
await todoStore1.size() // 0
await todoStore2.size() // 1
As you can see above, although the stores have the same name, they now exist in different apps (database), therefore they do not point to the same document/table.
By default, all store's data will be kept in memory. That means that when you create a store, its storage type is MEMORYSTORAGE
.
// get all the types
import {StorageType} from "client-web-storage";
There are four storage types:
MEMORYSTORAGE
- data stored in-memoryLOCALSTORAGE
- data stored in localStorageINDEXEDDB
- data stored in IndexedDBWEBSQL
- data stored in WebSQL
You change the storage type by setting the type
in the ClientStore Configuration.
const todo = new ClientStore("todo", {
$name: String
}, {
type: StorageType.INDEXEDDB
});
Not all stores should be of same type. Some data you want to persist between sessions and others should disappear when the session is over. CWS allows you to control your data to the tiniest details and having the ability to decide how each piece of data is stored is crucial.
Based on the storage type, versioning is important. For INDEXEDDB
and WEBSQL
stores you can set the version
of your store which can be extremely useful to track changes in the schema and other store configurations.
const todo = new ClientStore("todo", {
$name: String
}, {
type: StorageType.INDEXEDDB,
version: 1
});
If something about the store configuration or schema changes, bump the version. The next time the user loads your app the old stored data in the browser will not be considered, and you will avoid having to deal with data which no longer matches your updated app store schema.
const todo = new ClientStore("todo", {
$name: String,
description: "No Description" // new schema key
}, {
type: StorageType.INDEXEDDB,
version: 2 // update
});
The stores you create will always give all items an unique identifier and track the time each item was created and last updated.
The stores do that by setting default keys
in each item. Be default they are:
_id
_createdDate
_lastUpdatedDate
You can override these in configuration perhaps because you want these keys to match your data schema. You can do that in the store configuration without compromising the internal behavior around these keys.
Let's say you have the following interface for each item:
interface ToDo {
id: string;
dateCreated: Date;
dateUpdated: Date;
name: string;
description: string;
complete: boolean;
}
If you use it to create your store, you will have to manually remember to set and track them on every action. That also does not prevent the store to create its own internal item keys.
const todoStore = new ClientStore<ToDo>("todo", {
$id: String,
$dateCreated: Date,
$dateUpdated: Date,
$name: String,
description: "No Description",
complete: false
});
await todoStore.createItem({
id: uuid(),
dateCreated: new Date(),
dateUpdated: new Date(),
name: "Go to Gym"
});
/* Creates item in the store
{
_id: "123e4567-e89b-12d3-a456-426614174000",
_createdDate: "January, 4th 2022",
_lastUpdatedDate: "January, 4th 2022",
id: "123e4567-e89b-12d3-a456-426614174000",
dateCreated: "January, 4th 2022",
dateUpdated: "January, 4th 2022",
name: "Go to Gym",
description: "No Description",
complete: false,
}
*/
We can improve this by overriding the store default key names and let the store handling things.
const todoStore = new ClientStore<ToDo>("todo", {
$name: String,
description: "No Description",
complete: false
}, {
idKeyName: "id",
createdDateKeyName: "dateCreated",
updatedDateKeyName: "dateUpdated",
});
await todoStore.createItem({
name: "Go to Gym"
});
/* Creates item in the store
{
id: "123e4567-e89b-12d3-a456-426614174000",
dateCreated: "January, 4th 2022",
dateUpdated: "January, 4th 2022",
name: "Go to Gym",
description: "No Description",
complete: false,
}
*/
As you can see, you get the desired result while simplifying the way you interact with the store.
A store schema is a way to:
- Guarantee data format. (All fields will always exist with set or default values)
- Validate data. The store will make sure required fields are always set and all fields have expected data types.
One thing developers always be doing is setting default values and doing data checks when updating store data. CWS ensures that is done at the store level and developers can focus on other data logic.
There are two ways to define the schema:
- Create an Object literal (used in examples above);
- Create a Schema instance;
A schema object is simply a Javascript object literal. Simply create a object literal representing your item interface.
Given the following interface:
interface ToDo {
id: string;
dateCreated: Date;
dateUpdated: Date;
name: string;
description: string;
complete: boolean;
user: {
name: string;
avater: string;
}
}
Create the schema Object:
const ToDoSchema = {
id: SchemaId,
dateCreated: Date,
dateUpdated: Date,
$name: String,
description: "No Description",
complete: false,
user: {
id: SchemaId,
$name: String,
avater: String,
}
}
As you can see the difference between the typescript interface and the schema object is minimal:
- You use Javascript data type constructors instead (
String
,Date
,Boolean
, etc); - You can also use provided custom types from CWS (
SchemaId
,ArrayOf
,OneOf
); - Use the
$
sign to mark fields that user must provide on creation (in this case:name
,$user.name
); - Set a default value the store can use when a value is not provided (in this case
description
andcomplete
);
When setting the description
the "No Description"
is provided to the schema. The store will know that descrioption
is of type String
and that it is not required, therefore if when creating an item the description
field is not
specified, the store will use the value "No Description"
.
Same goes for the complete
field. We could simply set the type to be Boolean
and the default value for booleans is false
(check data types table) but we decided to explicitly set it to false
. In this case the store will know that the
field must be a Boolean
and use false
as default value when the field is not specified.
As you can see above, the schema object uses javascript types plus additional CWS types to help you define the type of data for your store.
Below is all supported types compared to typescript
to show that the difference is minimal
Typescript Example | CWS/Javascript Example | Type | Store Default Value |
---|---|---|---|
boolean |
Boolean |
Native | false |
string |
String |
Native | "" |
number |
Number |
Native | 0 |
null |
Null |
CWS | null |
Date |
Date |
Native | null |
Array |
Array |
Native | [] |
Array<String> |
ArrayOf(String) |
CWS | [] |
String | Number |
OneOf([String, Number], defaultValue) |
CWS | defaultValue set |
Array<String | Number> |
ArrayOf(OneOf([String, Number], "")) |
CWS | [] |
string |
SchemaId |
CWS | (new SchemaId()).defaultValue |
Record<K, V> |
Schema |
CWS | {} |
Blob |
Blob |
Native | null |
ArrayBuffer |
ArrayBuffer |
Native | null |
Float32Array |
Float32Array |
Native | new Float32Array() |
Float64Array |
Float64Array |
Native | new Float64Array() |
Int8Array |
Int8Array |
Native | new Int8Array() |
Int16Array |
Int16Array |
Native | new Int16Array() |
Int32Array |
Int32Array |
Native | new Int32Array() |
Uint8Array |
Uint8Array |
Native | new Uint8Array() |
Uint8ClampedArray |
Uint8ClampedArray |
Native | new Uint8ClampedArray() |
Uint16Array |
Uint16Array |
Native | new Uint16Array() |
Uint32Array |
Uint32Array |
Native | new Uint32Array() |
The schema object will be converted to a Schema
instance under the hood, and it is a much easier way to define a store
instance.
To create a schema simply instantiate the Schema
class which takes a required name and optional schema map value.
const todoSchema = new Schema("todo");
Now given the follow todo interface:
interface ToDo {
id: string;
dateCreated: Date;
dateUpdated: Date;
name: string;
description: string;
complete: boolean;
user: {
name: string;
avater: string;
}
}
We can define our todo schema like so:
const userSchema = new Schema("user");
userSchema.defineField("name", String, {required: true});
userSchema.defineField("avatar", String);
const todoSchema = new Schema("todo");
todoSchema.defineField("name", String, {required: true});
todoSchema.defineField("id", SchemaId);
todoSchema.defineField("dateCreated", Date);
todoSchema.defineField("dateUpdated", Date);
todoSchema.defineField("description", String, {defaultValue: "No Description"});
todoSchema.defineField("complete", Boolean);
todoSchema.defineField("user", userSchema, {required: true});
You may also define the fields during instantiation;
const userSchema = new Schema("user", {
name: new SchemaValue(String, true),
avatar: new SchemaValue(String),
});
const todoSchema = new Schema("todo", {
name: new SchemaValue(String, true),
id: new SchemaValue(SchemaId),
dateCreated: new SchemaValue(Date),
dateUpdated: new SchemaValue(Date),
description: new SchemaValue(String, false, "No Description"),
complete: new SchemaValue(Boolean),
user: new SchemaValue(userSchema, true),
});
As you can see, dealing with the Schema and SchemaValue is a little verbose and the reason the schema object is a much simpler way to define your store schema. This is to show what is the store is doing under the hood.
You can always access the store schema via the schema
property and if so, you should learn more about the Schema
api.
const todoStore = new ClientStore<ToDo>("todo", {
$name: String,
description: "No Description",
complete: false
});
todoStore.schema // return Schema instance
Any store you create is asynchronous and event driven. This means that any operation you perform does not block execution and can be reacted to and intercepted. This makes any store unique and powerful to work with.
Let's consider the following simple todo store:
const todoStore = new ClientStore<ToDo>("todo", {
$name: String,
description: "No Description",
complete: false
});
You can create any item by only providing the required fields and relying on the default values you set in the schema definition.
await todoStore.createItem({
name: "Go to Gym"
});
/* Creates
{
_id: "123e4567-e89b-12d3-a456-426614174000",
_createdDate: "January, 4th 2022",
_lastUpdatedDate: "January, 4th 2022",
name: "Go to Gym",
description: "No Description",
complete: false,
}
*/
await todoStore.createItem({
name: "Buy groceries",
description: "Buy ingredients for the dinner tommorrow"
});
/* Creates
{
_id: "123e4567-e89b-12d3-a456-426614174000",
_createdDate: "January, 4th 2022",
_lastUpdatedDate: "January, 4th 2022",
name: "Buy groceries",
description: "Buy ingredients for the dinner tommorrow",
complete: false,
}
*/
The createItem
method is asynchronous and returns the item if created or null
if the action is aborted. It takes
an object partially representing the item you are creating.
As you can see, even though you only specify a couple of properties, the store guarantees that all fields will exist
by using the default values based on type or that you specifically defined in your schema like we did with description
and complete
.
You can always read the entire store or a single item with the methods getItem
and getItems
.
await todoStore.getItems();
// return an array with all existing items
await todoStore.getItem("123e4567-e89b-12d3-a456-426614174000");
// returns the item or null
The updateItem
takes partial information to update the item in the store. It returns the updated item or null
in case the action got aborted or the item does not exist in the store.
await todoStore.updateItem("123e4567-e89b-12d3-a456-426614174000", {
complete: true
});
There are times which you simply need to do a bulk update or item creation. The loadItems
method allows you to do just that.
It will create the item if it does not exist otherwise update it.
// will create all items
const items = await todoStore.loadItems([
{
name: "Go to Gym"
},
{
name: "Buy groceries",
description: "Buy ingredients for the dinner tommorrow"
}
]);
// will update all items
await todoStore.loadItems(items.map(item => ({...item, complete: true})))
This method always returns an array of items unless the action got aborted. In that case it returns null
.
Whenever you want to remove a sinle item in the store, you call the removeItem
with the id of the item
await todoStore.removeItem("123e4567-e89b-12d3-a456-426614174000");
This method will return the id of the item if succeeded, otherwise null
if action got aborted or the item does not exist.
To clear the entire store, it is a simple as calling the clear
method.
await todoStore.clear();
The clear
method will return all the id of the item which got deleted or null
in case the action got aborted.
The CWS provides two methods to allow you to find any item in the store: findItem
and findItems
.
They are both asynchronous and take a comparator function which must return a boolean whether it is a match or not.
// find by name
const item = await todoStore.findItem(item => item.name === "Go to Gym");
// find all completed items
const items = await todoStore.findItems(item => item.complete);
Any action you perform in a store can be:
- aborted - cancel the action
- intercepted - perform additional actions before they get to the store
- subscribed to - perform action after they get in the store
As you can see, you can perform actions before and after an item gets to the store.
There are various store events you can tap into as you need to.
READY
- the store got successfully initializedPROCESSING
- the store is performing single or multiple actionsCREATED
- item was createdREMOVED
- item was removedUPDATED
- item was updatedLOADED
- items got loadedCLEARED
- store got clearedERROR
- some error happened performing an actionABORTED
- an action got aborted
Only CRUD operations can be intercepted, all of them can be subscribed to though.
You may simply subscribe and unsubscribe to a store.
const unsubscribe = todoStore.subscribe((eventType, dataAssociatedWithEvent) => {
switch (eventType) {
case ClientStore.EventType.READY:
// handle event type here
break;
case ClientStore.EventType.CREATED:
// handle event type here
break;
case ClientStore.EventType.UPDATED:
// handle event type here
break;
case ClientStore.EventType.LOADED:
// handle event type here
break;
case ClientStore.EventType.CLEARED:
// handle event type here
break;
case ClientStore.EventType.REMOVED:
// handle event type here
break;
case ClientStore.EventType.PROCESSING:
// handle event type here
break;
case ClientStore.EventType.ABORTED:
// handle event type here
break;
case ClientStore.EventType.ERROR:
// handle event type here
break;
default:
}
});
unsubscribe();
The data you receive in the un the subscription handler varies based on the event, so it is always great to check before performing any additional action. Because it is a subscription, the data is the result of an action after it happened.
The subscribe
method is nice because it provides you a single place to handle everything, but sometimes you only care
about a specific action and rather subscribe to that action directly.
For that there are the on
and off
methods that allows you to start and stop listening to specific events.
const stopListenToProcessingEvent = todoStore.on(EventType.PROCESSING, (processing: boolean) => {
// side effect logic here
});
stopListenToProcessingEvent();
Above example uses the returned off
function to clean the listener, but you may also call the off
method yourself
passing the same function instance as you can see bellow:
const handleProcessingEvent = (processing: boolean) => {
// side effect logic here
}
todoStore.on(EventType.PROCESSING, handleProcessingEvent);
todoStore.off(EventType.PROCESSING, handleProcessingEvent);
Your stores come with the intercept
and beforeChange
methods which you can use to perform various things before the
item is handled and saved in the store. These are called with the data the CRUD methods got called with to perform an action.
This is useful to:
- perform data validation;
- call API and make sure the data is changed/created remotely before store is changed locally
- perform data transformations;
Both intercept
and beforeChange
handler functions can:
- return
null
to abort an action; - return updated data to resume the action;
- throw error to be caught by the store and trigger a
ERROR
event;
The beforeChange
takes a handler function which will be called with the event and the data which any action got called with.
const unsub = todoStore.beforeChange(async (eventType, data) => {
switch (eventType) {
case EventType.CREATED:
// handle event type here
break;
case EventType.UPDATED:
// handle event type here
break;
case EventType.LOADED:
// handle event type here
break;
case EventType.REMOVED:
// handle event type here
break;
case EventType.CLEARED:
// handle event type here
break;
default:
};
});
unsub()
As you can see, the handler function can be asynchronous which allows you to do whatever you want.
The intercept
is similar to beforeChange
method. The difference is that it allows you to intercept a specific event.
const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, (data) => {
// side effect logic here
});
stopInterceptingCreateEvent();
It is safe to throw an error inside the intercept
and beforeChange
handlers. The error is caught by the store and a
Error
event is created.
This allows you to subscribe or listen to all errors in a single place and not worry about try...catch
blocks inside
handlers unless you really need to.
To abort an action all you need to do is return null
in the interceptors event handlers.
Below example will make sure the store size will never be over 10 items.
const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, async (data) => {
if (await todoStore.size() === 10) {
return null;
}
});
await todoStore.createItem({
name: "Go to Gym"
})
stopInterceptingCreateEvent();
The beauty of intercepting is that you can do whatever you need to do before ensuring the data is okay. Bellow is a simple example that will throw an error if the todo name is invalid.
const removeErrorListerner = todoStore.on(EventType.ERROR, (error) => {
displayAppErrorBanner(error.message);
})
const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, async (data) => {
if (isValidTodoName(data.name)) {
await todoService.createTodo(data);
} else {
throw new Error('Invalid todo name')
}
});
try {
await todoStore.createItem({
name: "$%$%$%$%$%$"
})
} catch(e) {
handleError(e);
}
// when no longer needed
stopInterceptingCreateEvent();
removeErrorListerner();
Sometimes you need to sync the current store data with the backend one. Perhaps you need the actual id
generated in the backend
and not the client one.
const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, async (data) => {
const res = await todoService.createTodo(data);
// return new data to override the data
// the action was called with
return {
...res,
_id: res.identifier,
_lastUpdatedDate: res.updatedDate,
}
});
await todoStore.createItem({
name: "Go Shopping"
})
// when no longer needed
stopInterceptingCreateEvent();
A common use case to intercepting data is to transform it before saving it. Perhaps the store actions are called with data which does not match the interface format which needs to be mapped or data which needs to be changed in some way before saving.
const stopInterceptingCreateEvent = todoStore.intercept(EventType.CREATED, async (data) => {
data.name = encode(data.name);
data.description = encode(data.description);
await todoService.createTodo(data);
return data; // return the new data to override
});
await todoStore.createItem({
name: "go to gym",
description: "some unsafe data collected from user input"
})
// when no longer needed
stopInterceptingCreateEvent();
The ClientStore is perfect to handle data of your application in a list or database style. However, sometime you just need specific application data which are not necessarily the data of the users or that come from the server or that needs to be manipulated by your application.
Such data are what we call metadata. They are things which helps you decide how to display the UI or how to behave. They are your application configuration and settings which can be global or specific to a part of your application.
For such data you can't represent them as items in a store. For those you should not use ClientStore
. That's why
we have the AppState
class to handle such things.
interface State {
theme: "light" | "dark";
language: "en" | "pt";
}
const appState = new AppState<State>("todo", {
theme: "light",
language: "en",
});
Above is a simple example on where to store metadata like the theme
and language
of the application.
AppState
inherits all the benefits of the ClientStore
. It allows you to subscribe and intercept data. It also validates
the state on every action allowing you to have full control of the state.
To access the data you use the value
property which returns the state at its current value. But the best
way to be up-to-date with the state is by subscribing to the store
appState.value; // returns the state
The AppState
exposes the update
method which is the only way to change the state. State fields cannot be removed or added
after the initialization. You may only update their value. The store will set the defaults as necessary.
appState.update({
theme: "dark"
})
appState.update({
language: "pt"
})
You may always subscribe to the application state to react to every change.
appState.subscribe((state) => {
// handle state
})
Sometimes you need to perform validation or transformation on the state data before they make it in. For that you can
use the intercept
method.
The intercept
method of AppState
is different from ClientStore
in a sense that it does not take the event you
want to subscribe to. You only need to provide the handler and like in the store, you:
- return new data to override;
- throw error to cancel action;
- return null to abort the action in general;
appState.intercet((dataUsedToUpdateTheState) => {
// handle data
})
The Client-Web-Storage
package exposes various helpers which are intended to help you incorporate the stores into
you application much easier.
React helper that given a store instance or name, provides a store state which is much easier to interact or consume store data.
It exposes a hook and a provider.
import {useClientStore, ClientStoreProvider} from "client-web-storage/helpers/use-client-store";
You can choose to inject all your stores at the top level of your app or section or your app
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<ClientStoreProvider stores={[todoStore]}>
<App />
</ClientStoreProvider>
);
Then simply consume the store like so:
// app.tsx
import {useClientStore} from "client-web-storage/helpers/use-client-store";
const App = () => {
const todoStore = useClientStore<Todo>("todo");
const handleCreateItem = async () => {
await todoStore.createItem({
name: "todo-" + crypto.randomUUID()
})
}
return (
<>
<h2>Todos</h2>
<ActionBar>
<Button onClick={handleCreateItem} >Create Todo</Button>
</ActionBar>
{todoStore.loadingItems
? <Spinner/>
: todoStore.error
? <Status type="error" message={todoStore.error.message} />
: todoStore.items.map(todo => <TodoItem data={todo} />)}
</>
)
}
React helper that given a app state instance or name, provides a store state which is much easier to interact or consume data.
It exposes a hook and a provider.
import {useAppState, AppStateProvider} from "client-web-storage/helpers/use-app-state";
You can choose to inject all your stores at the top level of your app or section or your app
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<AppStateProvider states={[appAppState]}>
<App />
</AppStateProvider>
);
// app.tsx
import {useAppState} from "client-web-storage/helpers/use-app-state";
const App = () => {
const {state, setState, error} = useAppState<AppStateType>(appState);
...
}
A Higher Order Function which can be used with any UI framework to easily consume the store data.
Bellow is an example on how to use it with Angular.
// app.component.ts
import {StoreState} from "client-web-storage";
import {withClientStore, DefaultStoreState} from "client-web-storage/helpers/with-client-store";
import {todoStore, Todo} from "./stores/todo.store";
@Component({
selector: 'app-root',
})
export class AppComponent implements OnInit, OnDestroy {
$todo: StoreState<Todo> = DefaultStoreState;
$unsubscribeFromTodoStore: UnSubscriber;
ngOnInit() {
this.$unsubscribeFromTodoStore = withClientStore<Todo>(todoStore, (data) => {
// handle data;
});
}
ngOnDestroy() {
this.$unsubscribeFromTodoStore();
}
}