For this guide, we will be using https://github.com/SocketCluster/sc-crud-sample to go through various concepts of real-time CRUD. For part 1, we will only consider the front end of our sc-crud-sample
application.
To implement real-time CRUD, we need the following things:
For the sc-crud-sample
app, one of the goals was to keep the front end as simple as possible so that it could work without bundling tools or compile steps. You may still want to bundle all your front end scripts together when serving the app in production (e.g. to speed up loading and to support old browsers) but not having to depend on a bundler (e.g. WebPack) during development means that you can iterate more quickly when coding your front end. Also, if you want to switch to HTTP2 now or in the future (e.g. to push assets to clients without latency), then you may decide that you don’t need a bundler altogether.
The main **index.html**
looks like this:
^ Here we’re just importing a CSS stylesheet and defining some global styles inline. If our app was bigger, we’d probably want to keep all our CSS style definitions in separate files but for now we chose to keep it simple.
In the above definition, we have a tag <div id="app"></div>
— Vue will replace this tag with our app-inventory
component; this component is defined inside our script which we import via the<script src="/app-inventory.js" type="module"></script>
tag. The app-inventory
component is the entry point of our entire VueJS front end logic. Note that we use the ES6 modules syntax to import dependencies within our code.
Note that for our application scripts, we try to use paths relative to the root directory; this makes it easier to find things when reading through various parts of the code.
Our **app-inventory.js**
module looks like this:
^ This file contains all the logic required to bootstrap our front end application. At the top, we’re importing some scripts/functions which will allow us to generate all the different pages within our app.
let socket = window.socket = socketCluster.create();
^ This creates a WebSocket-based socketcluster-client
socket object which our SCModel
and SCCollection
components will use to load data from and send data to our server and through which those components will receive change notifications when data changes on the server side.
let pageOptions = {socket};
let PageCategoryList = getCategoryListPageComponent(pageOptions);let PageCategoryDetails = getCategoryDetailsPageComponent(pageOptions);let PageProductDetails = getProductDetailsPageComponent(pageOptions);let PageLogin = getLoginPageComponent(pageOptions);
^ Here we’re constructing all the VueJS page components which are used in our app. We’re passing a reference to our socket to every page component so that they can bind their own SCModel
and SCCollection
components to it. We share a single global socket object with all our components in the same way that we would share a single global store between all components if we were following the Redux architecture — In our case, this approach allows multiple components to share the same pub/sub channels to consume real-time data from the server.
let routes = [{path: '/category/:categoryId/product/:productId',component: PageProductDetails,props: true},{path: '/category/:categoryId',component: PageCategoryDetails,props: true},{path: '/',component: PageCategoryList,props: true}];
let router = new VueRouter({routes});
^ We define all the routes in our app and link them to the relevant page components; we pass everything to VueRouter and let it do its magic.
Everything is pretty standard VueJS boilerplate. The most interesting part is at the bottom of the script:
template: `<div><div v-if="isAuthenticated" style="padding: 10px;"><router-view></router-view></div><div v-if="!isAuthenticated" style="padding: 10px;"><page-login></page-login></div></div>`
^ This means that if isAuthenticated
is true, VueJS will render our router (which will render the relevant page based on the current URL path). If isAuthenticated
is false, VueJS will show the user with a login page from which they will be able to login. SocketCluster supports JWT authentication, so we will listen for an authStateChange
event on the socket to check when it becomes authenticated; we will update our Vue app component’s isAuthenticated
property to reflect the authState
of our socket. This is the important part:
created: function () {this.isAuthenticated = this.isSocketAuthenticated();socket.on('authStateChange', () => {this.isAuthenticated = this.isSocketAuthenticated();});
...
Then there is some ugly code just below it…
...
this.\_localStorageAuthHandler = (change) => {
// In case the user logged in from a different tab
if (change.key === socket.options.authTokenName) {
if (this.isAuthenticated) {
if (!change.newValue) {
socket.deauthenticate();
}
} else if (change.newValue) {
socket.authenticate(change.newValue);
}
}
};
window.addEventListener(
'storage',
this.\_localStorageAuthHandler
);
^ That’s just to make authentication work across multiple browser tabs; so in our case, if the user has our app open in multiple tabs and they log into (or log out of) either one of those tabs, then we want the authentication state of our app to update immediately to match across both open tabs.
The **page-login.js**
module looks like this:
^ The reason why we export a getPageComponent
function is to allow us to receive the global socket
object from our upstream app component.
The most important code in this component is:
socket.emit('login', details, (err, failure) => {if (err) {this.error = 'Failed to login due to error: ' + err;} else if (failure) {this.error = failure;} else {this.error = '';}});
^ This will attempt to authenticate our client socket. The login
event will be handled by our custom server-side logic; it will check if the username and password combination is correct and, if so, our client socket will emit an authStateChange
event; if you recall our logic (and template) in app-inventory
, this event will cause VueJS to hide our login page and activate our Vue router component.
Once the router becomes activated, it will render one of our page components depending on our the current URL. All our pages are relatively similar in their basic structure so we will analyse the most advanced one which is the component in **page-category-details.js**
; this page represents a detailed view of a Category
which contains a bunch of Product
resources. In our app, all Product
resources are associated with a Category
; this allows us to filter items based on which group they belong to.
This is what the category details screen looks like (URL http://localhost:8000/#/category/:categoryId
):
This page contains two tables which are lists of Product
resources. The table at the top represents a list of all Product
resources which belong to the current category (with Prev page
and Next page
links to navigate through all products). The second table underneath only lists up to 5 products which belong to the current category AND which are about to run out of stock soon (have the lowest qty). Both of these tables update in real-time as new products are added and as the qty and price of the products change — The best way to test this is to open the app in multiple browser tabs and watch the changes instantly sync between them.
The first text input box on the page lets you add new products to the current category. The second text input box lets you change the threshold qty for the second table so that you can reduce it down to the items that have the smallest qty — This feature shows that you can generate complex real-time views based on multiple parameters.
The code for the **page-category-details.js**
component looks like this:
At the very beginning, we import our SCCollection
and SCModel
components. The next most interesting part in this code is this:
props: {categoryId: String},
^ This categoryId
comes from VueRouter
which reads it from the URL. If our URL looks like this: http://localhost:8000/#/category/4dfbfe47-d2fe-4e12-9ba1-26c8877221e8
, then our categoryId
will be the string "4dfbfe47-d2fe-4e12-9ba1-26c8877221e8"
.
The next most important part in our code is this:
data: function () {this.categoryModel = new SCModel({socket: pageOptions.socket,type: 'Category',id: this.categoryId,fields: ['name', 'desc']});
...
^ This is where some of the real-time updating magic happens — Here we’re creating a Category
model instance; this front end object will use the socket
object to essentially bind itself to the relevant resource on the server and will make sure that it always shows the latest data. The SCModel
also exposes methods to update the underlying resource on the server side.
The type
property here refers to the table name inside our RethinkDB database, the id
is the id of the resource in the database (RethinkDB uses uuids
). The fields
property is an array of fields that we want to include as part of this model — the underlying resource may have many more fields; so a bit like with GraphQL, we only specify the ones which we need for the current page.
The following code is where we instantiate our main SCCollection
which will provide data to the table at the top of the page:
this.productsCollection = new SCCollection({socket: pageOptions.socket,type: 'Product',fields: ['name', 'qty', 'price'],view: 'categoryView',viewParams: {category: this.categoryId},pageOffset: 0,pageSize: 5,getCount: true});
^ Like in SCModel
, the type
property refers to the table name in RethinkDB, also as before, the fields
property is an array of fields that we want to include as part of this collection.
The view
property holds the name of the view which this collection represents; a view is just a transformation applied to a collection (e.g. a filtered/sorted subset). In our case, the categoryView
represents a subset of the Product
collection which only contains products whose category
field matches the current categoryId
of our page.
The viewParams
option holds an object which contains parameters which we pass to our view generator on the back end; the name of the properties of the viewParams
object must match a field name on the underlying resource (in this case the Product
resource has a category
field). The productsCollection
is relatively simple because it only has a single viewParams
property — That said, it’s possible to have collections without any viewParams
; it’s also possible to have a collection without views; in that case, the collection component will just bind to the entire database table without any filtering.
The pageOffset
option refers to the offset index from which to start loading the collection (starting at the nth item). The pageSize
defines the maximum number of items that the collection can contain at any given time. The getCount
property is a boolean which indicates whether or not we should load metadata about the total number of resources in the collection.
The next collection which is declared in our component is the lowStockProductsCollection
:
this.lowStockProductsCollection = new SCCollection({socket: pageOptions.socket,type: 'Product',fields: ['name', 'qty', 'price'],view: 'lowStockView',viewParams: {category: this.categoryId, qty: lowStockThreshold},viewPrimaryKeys: ['category'],pageOffset: 0,pageSize: 5,getCount: false});
^ This SCCollection
provides the data for the second table on the category details page. It looks very similar to the productsCollection
except for a few properties. The first difference is that it uses a different view — lowStockView
instead of categoryView
; also it has one additional viewParams
: a qty
property. The qty
property allows us to adjust the threshold used to transform/filter our lowStockView
.
Another major difference is that the lowStockView
defines a viewPrimaryKeys
array which contains 'category'
as the only primary key; this is required because real-time filtered views rely on the fact that some equality relationships between resources exist within the view (e.g. All products which belong to the same categoryView
have the same value as their category
property). Since in this case we’re modeling an inequality relationship (e.g. in this case, our view represents a relationship where Product.qty
is less than lowStockThreshold
), we cannot include it as part of our view’s primary key; we still get some performance benefits from having category
as our primary key (better than no primary key).
By default, the viewPrimaryKeys
property is optional; if not provided, all the properties in viewParams
will be used as primary keys — being explicit about the primary keys allows us to meet the equality requirement for optimum real-time updates on collections. Important note: If primaryKeys
are specified for a view in your back end schema (in worker.js
), then you must also specify matching viewPrimaryKeys
for your collection on the front end.
Note that you can pass an empty array as viewPrimaryKeys
; this will guarantee that automatic real-time updates of the collection will work but the downside is that this approach will cause the collection to subscribe to every change notification on the entire table (instead of only a small affected subset as is the case for categoryView
) — the category primary key acts as a natural sharding mechanism; the more categories there are, the more efficiently change notifications will be delivered to users.
For the back end guide, see: Real-time CRUD guide: Back end (part 2)