diff --git a/web/app/events.py b/web/app/events.py
index df716d81c4ef03221c08675be4c41a8e0112f23d..a0f76b3b98611ed11e2fe60fa83b2814f635c53d 100644
--- a/web/app/events.py
+++ b/web/app/events.py
@@ -33,38 +33,24 @@ def disconnect():
     connected_sessions.remove(request.sid)
 
 
-@socketio.on('user_data_stream_init')
+@socketio.on('start_user_session')
 @socketio_login_required
-def user_data_stream_init():
-    socketio.start_background_task(user_data_stream,
+def start_user_session(user_id):
+    if not (current_user.id == user_id or current_user.is_administrator):
+        return
+    socketio.start_background_task(user_session,
                                    current_app._get_current_object(),
-                                   current_user.id, request.sid)
+                                   user_id, request.sid)
 
 
-@socketio.on('foreign_user_data_stream_init')
-@socketio_login_required
-@socketio_admin_required
-def foreign_user_data_stream_init(user_id):
-    socketio.start_background_task(user_data_stream,
-                                   current_app._get_current_object(),
-                                   user_id, request.sid, foreign=True)
-
-
-def user_data_stream(app, user_id, session_id, foreign=False):
+def user_session(app, user_id, session_id):
     '''
-    ' Sends initial corpus and job lists to the client. Afterwards it checks
-    ' every 3 seconds if changes to the initial values appeared. If changes are
-    ' detected, a RFC 6902 compliant JSON patch gets send.
-    '
-    ' NOTE: The initial values are send as a init events.
-    '       The JSON patches are send as update events.
+    ' Sends initial user data to the client. Afterwards it checks every 3s if
+    ' changes to the initial values appeared. If changes are detected, a
+    ' RFC 6902 compliant JSON patch gets send.
     '''
-    if foreign:
-        init_event = 'foreign_user_data_stream_init'
-        update_event = 'foreign_user_data_stream_update'
-    else:
-        init_event = 'user_data_stream_init'
-        update_event = 'user_data_stream_update'
+    init_event = 'user_{}_init'.format(user_id)
+    patch_event = 'user_{}_patch'.format(user_id)
     with app.app_context():
         # Gather current values from database.
         user = User.query.get(user_id)
@@ -80,7 +66,7 @@ def user_data_stream(app, user_id, session_id, foreign=False):
                                                        new_user_dict)
             # In case there are patches, send them to the client.
             if user_patch:
-                socketio.emit(update_event, user_patch.to_string(),
+                socketio.emit(patch_event, user_patch.to_string(),
                               room=session_id)
             # Set new values as references for the next iteration.
             user_dict = new_user_dict
diff --git a/web/app/static/css/nopaque.css b/web/app/static/css/nopaque.css
index 3ad3a9141eaab94dbe716eb1f7d3fc8c5d0c03d7..597701aa3692d923dc6a6824880b32d2bb72b0e6 100644
--- a/web/app/static/css/nopaque.css
+++ b/web/app/static/css/nopaque.css
@@ -8,6 +8,10 @@ main {
   margin-top: 48px;
 }
 
+table.ressource-list tr {
+  cursor: pointer;
+}
+
 .parallax-container .parallax {
   z-index: auto;
 }
diff --git a/web/app/static/js/nopaque.js b/web/app/static/js/nopaque.js
index 5e3ad92ab56225485b26e1e6d78db4b510240cca..bd5e3f4ebbfcb63bb3356cdbce35ca30eeb590f2 100644
--- a/web/app/static/js/nopaque.js
+++ b/web/app/static/js/nopaque.js
@@ -1,96 +1,138 @@
-/*
- * The nopaque object is used as a namespace for nopaque specific functions and
- * variables.
- */
-var nopaque = {};
-
-// User data
-nopaque.user = {};
-nopaque.user.settings = {};
-nopaque.user.settings.darkMode = undefined;
-nopaque.corporaSubscribers = [];
-nopaque.jobsSubscribers = [];
-nopaque.queryResultsSubscribers = [];
-
-// Foreign user (user inspected with admin credentials) data
-nopaque.foreignUser = {};
-nopaque.foreignUser.isAuthenticated = undefined;
-nopaque.foreignUser.settings = {};
-nopaque.foreignUser.settings.darkMode = undefined;
-nopaque.foreignCorporaSubscribers = [];
-nopaque.foreignJobsSubscribers = [];
-nopaque.foreignQueryResultsSubscribers = [];
-
-// nopaque functions
-nopaque.socket = io({transports: ['websocket']});
-// Add event handlers
-nopaque.socket.on("user_data_stream_init", function(msg) {
-  nopaque.user = JSON.parse(msg);
-  for (let subscriber of nopaque.corporaSubscribers) {
-    subscriber.init(nopaque.user.corpora);
+class AppClient {
+  constructor(currentUserId) {
+    this.socket = io({transports: ['websocket']});
+    this.users = {};
+    this.users.self = this.loadUser(currentUserId);
   }
-  for (let subscriber of nopaque.jobsSubscribers) {
-    subscriber.init(nopaque.user.jobs);
-  }
-  for (let subscriber of nopaque.queryResultsSubscribers) {
-    subscriber.init(nopaque.user.query_results);
-  }
-});
-
-nopaque.socket.on("user_data_stream_update", function(msg) {
-  var patch;
-
-  patch = JSON.parse(msg);
-  nopaque.user = jsonpatch.apply_patch(nopaque.user, patch);
-  corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora"));
-  jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs"));
-  query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results"));
-  for (let subscriber of nopaque.corporaSubscribers) {
-    subscriber.update(corpora_patch);
+
+  loadUser(userId) {
+    let user = new User();
+    this.users[userId] = user;
+    this.socket.on(`user_${userId}_init`, msg => user.init(JSON.parse(msg)));
+    this.socket.on(`user_${userId}_patch`, msg => user.patch(JSON.parse(msg)));
+    this.socket.emit('start_user_session', userId);
+    return user;
   }
-  for (let subscriber of nopaque.jobsSubscribers) {
-    subscriber.update(jobs_patch);
+}
+
+
+class User {
+  constructor() {
+    this.data = undefined;
+    this.eventListeners = {
+      corporaInit: [],
+      corporaPatch: [],
+      jobsInit: [],
+      jobsPatch: [],
+      queryResultsInit: [],
+      queryResultsPatch: []
+    };
   }
-  for (let subscriber of nopaque.queryResultsSubscribers) {
-    subscriber.update(query_results_patch);
+
+  init(data) {
+    this.data = data;
+
+    let listener;
+    for (listener of this.eventListeners.corporaInit) {
+      listener(this.data.corpora);
+    }
+    for (listener of this.eventListeners.jobsInit) {
+      listener(this.data.jobs);
+    }
+    for (listener of this.eventListeners.queryResultsInit) {
+      listener(this.data.query_results);
+    }
   }
-  if (["all", "end"].includes(nopaque.user.settings.job_status_site_notifications)) {
-    for (operation of jobs_patch) {
-      /* "/jobs/{jobId}/..." -> ["{jobId}", ...] */
-      pathArray = operation.path.split("/").slice(2);
-      if (operation.op === "replace" && pathArray[1] === "status") {
-        if (nopaque.user.settings.job_status_site_notifications === "end" && !["complete", "failed"].includes(operation.value)) {continue;}
-        nopaque.flash(`[<a href="/jobs/${pathArray[0]}">${nopaque.user.jobs[pathArray[0]].title}</a>] New status: ${operation.value}`, "job");
+
+  patch(patch) {
+    this.data = jsonpatch.apply_patch(this.data, patch);
+
+    let corporaPatch = patch.filter(operation => operation.path.startsWith("/corpora"));
+    let jobsPatch = patch.filter(operation => operation.path.startsWith("/jobs"));
+    let queryResultsPatch = patch.filter(operation => operation.path.startsWith("/query_results"));
+
+    for (let listener of this.eventListeners.corporaPatch) {
+      if (corporaPatch.length > 0) {listener(corporaPatch);}
+    }
+    for (let listener of this.eventListeners.jobsPatch) {
+      if (jobsPatch.length > 0) {listener(jobsPatch);}
+    }
+    for (let listener of this.eventListeners.queryResultsPatch) {
+      if (queryResultsPatch.length > 0) {listener(queryResultsPatch);}
+    }
+
+    for (let operation of jobsPatch) {
+      if (operation.op !== 'replace') {continue;}
+      // Matches the only path that should be handled here: /jobs/{jobId}/status
+      if (/^\/jobs\/(\d+)\/status$/.test(operation.path)) {
+        let [match, jobId] = operation.path.match(/^\/jobs\/(\d+)\/status$/);
+        if (this.data.settings.job_status_site_notifications === "end" && !["complete", "failed"].includes(operation.value)) {continue;}
+        nopaque.flash(`[<a href="/jobs/${jobId}">${this.data.jobs[jobId].title}</a>] New status: ${operation.value}`, "job");
       }
     }
   }
-});
 
-nopaque.socket.on("foreign_user_data_stream_init", function(msg) {
-  nopaque.foreignUser = JSON.parse(msg);
-  for (let subscriber of nopaque.foreignCorporaSubscribers) {
-    subscriber.init(nopaque.foreignUser.corpora);
-  }
-  for (let subscriber of nopaque.foreignJobsSubscribers) {
-    subscriber.init(nopaque.foreignUser.jobs);
-  }
-  for (let subscriber of nopaque.foreignQueryResultsSubscribers) {
-    subscriber.init(nopaque.foreignUser.query_results);
+  addEventListener(type, listener) {
+    switch (type) {
+      case 'corporaInit':
+        this.eventListeners.corporaInit.push(listener);
+        if (this.data !== undefined) {listener(this.data.corpora);}
+        break;
+      case 'corporaPatch':
+        this.eventListeners.corporaPatch.push(listener);
+        break;
+      case 'jobsInit':
+        this.eventListeners.jobsInit.push(listener);
+        if (this.data !== undefined) {listener(this.data.jobs);}
+        break;
+      case 'jobsPatch':
+        this.eventListeners.jobsPatch.push(listener);
+        break;
+      case 'queryResultsInit':
+        this.eventListeners.queryResultsInit.push(listener);
+        if (this.data !== undefined) {listener(this.data.query_results);}
+        break;
+      case 'queryResultsPatch':
+        this.eventListeners.queryResultsPatch.push(listener);
+        break;
+      default:
+        console.error(`Unknown event type: ${type}`);
+    }
   }
-});
+}
 
-nopaque.socket.on("foreign_user_data_stream_update", function(msg) {
-  var patch;
 
-  patch = JSON.parse(msg);
-  nopaque.foreignUser = jsonpatch.apply_patch(nopaque.foreignUser, patch);
-  corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora"));
-  jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs"));
-  query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results"));
-  for (let subscriber of nopaque.foreignCorporaSubscribers) {subscriber.update(corpora_patch);}
-  for (let subscriber of nopaque.foreignJobsSubscribers) {subscriber.update(jobs_patch);}
-  for (let subscriber of nopaque.foreignQueryResultsSubscribers) {subscriber.update(query_results_patch);}
-});
+/*
+ * The nopaque object is used as a namespace for nopaque specific functions and
+ * variables.
+ */
+var nopaque = {};
+
+nopaque.flash = function(message, category) {
+  let toast;
+  let toastActionElement;
+
+  switch (category) {
+    case "corpus":
+      message = `<i class="left material-icons">book</i>${message}`;
+      break;
+    case "error":
+      message = `<i class="left material-icons red-text">error</i>${message}`;
+      break;
+    case "job":
+      message = `<i class="left material-icons">work</i>${message}`;
+      break;
+    default:
+      message = `<i class="left material-icons">notifications</i>${message}`;
+  }
+
+  toast = M.toast({html: `<span>${message}</span>
+                          <button data-action="close" class="btn-flat toast-action white-text">
+                            <i class="material-icons">close</i>
+                          </button>`});
+  toastActionElement = toast.el.querySelector('.toast-action[data-action="close"]');
+  toastActionElement.addEventListener('click', () => {toast.dismiss();});
+};
 
 nopaque.Forms = {};
 nopaque.Forms.init = function() {
@@ -163,30 +205,3 @@ nopaque.Forms.init = function() {
     }
   }
 }
-
-
-nopaque.flash = function(message, category) {
-  let toast;
-  let toastActionElement;
-
-  switch (category) {
-    case "corpus":
-      message = `<i class="left material-icons">book</i>${message}`;
-      break;
-    case "error":
-      message = `<i class="left material-icons red-text">error</i>${message}`;
-      break;
-    case "job":
-      message = `<i class="left material-icons">work</i>${message}`;
-      break;
-    default:
-      message = `<i class="left material-icons">notifications</i>${message}`;
-  }
-
-  toast = M.toast({html: `<span>${message}</span>
-                          <button data-action="close" class="btn-flat toast-action white-text">
-                            <i class="material-icons">close</i>
-                          </button>`});
-  toastActionElement = toast.el.querySelector('.toast-action[data-action="close"]');
-  toastActionElement.addEventListener('click', () => {toast.dismiss();});
-}
diff --git a/web/app/static/js/nopaque.lists.js b/web/app/static/js/nopaque.lists.js
index 0ce961ed412218168c6ebcbdb116c78b7c9d098e..36ea8b48feccf4758db65abf11d7ac41aa210231 100644
--- a/web/app/static/js/nopaque.lists.js
+++ b/web/app/static/js/nopaque.lists.js
@@ -1,6 +1,33 @@
 class RessourceList {
-  constructor(idOrElement, options = {}) {
-    this.list = new List(idOrElement, {...RessourceList.options, ...options});
+  /* A wrapper class for the list.js list.
+   * This class is not meant to be used directly, instead it should be used as
+   * a template for concrete ressource list implementations.
+   */
+  constructor(listElement, options = {}) {
+    if (listElement.dataset.userId) {
+      if (listElement.dataset.userId in nopaque.appClient.users) {
+        this.user = nopaque.appClient.users[listElement.dataset.userId];
+      } else {
+        console.error(`User not found: ${listElement.dataset.userId}`);
+        return;
+      }
+    } else {
+      this.user = nopaque.appClient.users.self;
+    }
+    this.list = new List(listElement, {...RessourceList.options, ...options});
+    this.valueNames = ['id'];
+    for (let element of this.list.valueNames) {
+      switch (typeof element) {
+        case 'object':
+          if (element.hasOwnProperty('name')) {this.valueNames.push(element.name);}
+          break;
+        case 'string':
+          this.valueNames.push(element);
+          break;
+        default:
+          console.error(`Unknown value name definition: ${element}`);
+      }
+    }
   }
 
   init(ressources) {
@@ -9,38 +36,27 @@ class RessourceList {
     this.list.sort('id', {order: 'desc'});
   }
 
-
-  update(patch) {
-    let item, pathArray;
-
-    for (let operation of patch) {
-      /*
-       * '/{ressourceName}/{ressourceId}/{valueName}' -> ['{ressourceId}', {valueName}]
-       * Example: '/jobs/1/status' -> ['1', 'status']
-      */
-      let [id, valueName] = operation.path.split("/").slice(2);
-      switch(operation.op) {
-        case 'add':
-          this.add(operation.value);
-          break;
-        case 'remove':
-          this.remove(id);
-          break;
-        case 'replace':
-          this.replace(id, valueName, operation.value);
-          break;
-        default:
-          break;
-      }
-    }
+  patch(patch) {
+    /*
+     * It's not possible to generalize a patch Handler for all type of
+     * ressources. So this method is meant to be an interface.
+     */
+    console.error('patch method not implemented!');
   }
 
   add(values) {
-    /* WORKAROUND: Set a callback function ('() => {return;}') to force List.js
-                   perform the add method asynchronous.
-     * https://listjs.com/api/#add
-     */
-    this.list.add(values, () => {return;});
+    let ressources = Array.isArray(values) ? values : [values];
+    // Discard ressource values, that are not defined to be used in the list.
+    ressources = ressources.map(ressource => {
+      let cleanedRessource = {};
+      for (let [valueName, value] of Object.entries(ressource)) {
+        if (this.valueNames.includes(valueName)) {cleanedRessource[valueName] = value;}
+      }
+      return cleanedRessource;
+    });
+    // Set a callback function ('() => {return;}') to force List.js perform the
+    // add method asynchronous: https://listjs.com/api/#add
+    this.list.add(ressources, () => {return;});
   }
 
   remove(id) {
@@ -48,35 +64,84 @@ class RessourceList {
   }
 
   replace(id, valueName, newValue) {
-    if (!this.list.valuesNames.includes(valueName)) {return;}
-    let item = this.list.get('id', id);
-    item.values({[valueName]: newValue});
+    if (this.valueNames.includes(valueName)) {
+      let item = this.list.get('id', id)[0];
+      item.values({[valueName]: newValue});
+    }
   }
 }
-
-
 RessourceList.options = {page: 5, pagination: [{innerWindow: 4, outerWindow: 1}]};
 
 
 class CorpusList extends RessourceList {
-  constructor(listElementId, options = {}) {
-    let listElement = document.querySelector(`#${listElementId}`);
+  constructor(listElement, options = {}) {
     super(listElement, {...CorpusList.options, ...options});
-    listElement.addEventListener('click', (event) => {
-      let actionButtonElement = event.target.closest('.action-button');
-      if (actionButtonElement === null) {return;}
-      let corpusId = event.target.closest('tr').dataset.id;
-      let action = actionButtonElement.dataset.action;
-      switch (action) {
-        case 'analyse':
-          window.location.href = nopaque.user.corpora[corpusId].analysis_url;
-      }
-    });
-    nopaque.corporaSubscribers.push(this);
+    this.user.addEventListener('corporaInit', corpora => this.init(corpora));
+    this.user.addEventListener('corporaPatch', patch => this.patch(patch));
+    listElement.addEventListener('click', (event) => {this.onclick(event)});
   }
-}
 
+  onclick(event) {
+    let corpusId = event.target.closest('tr').dataset.id;
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+    switch (action) {
+      case 'analyse':
+        window.location.href = nopaque.user.corpora[corpusId].analysis_url;
+      case 'delete':
+        let deleteModalHTML = `<div class="modal">
+                                 <div class="modal-content">
+                                   <h4>Confirm corpus deletion</h4>
+                                   <p>Do you really want to delete the corpus <b>${nopaque.user.corpora[corpusId].title}</b>? All files will be permanently deleted!</p>
+                                 </div>
+                                 <div class="modal-footer">
+                                   <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
+                                   <a class="btn modal-close red waves-effect waves-light" href="${nopaque.user.corpora[corpusId].url}/delete"><i class="material-icons left">delete</i>Delete</a>
+                                 </div>
+                               </div>`;
+        let deleteModalParentElement = document.querySelector('main');
+        deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
+        let deleteModalElement = deleteModalParentElement.lastChild;
+        let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
+        deleteModal.open();
+        break;
+      case 'view':
+        // TODO: handle unprepared corpora
+        window.location.href = nopaque.user.corpora[corpusId].url;
+        break;
+      default:
+        console.error(`Unknown action: ${action}`);
+        break;
+    }
+  }
 
+  patch(patch) {
+    for (let operation of patch) {
+      switch(operation.op) {
+        case 'add':
+          // Matches the only paths that should be handled here: /corpora/{corpusId}
+          if (/^\/corpora\/(\d+)$/.test(operation.path)) {this.add(operation.value);}
+          break;
+        case 'remove':
+          // See case 'add' ;)
+          if (/^\/corpora\/(\d+)$/.test(operation.path)) {
+            let [match, id] = operation.path.match(/^\/corpora\/(\d+)$/);
+            this.remove(corpusId);
+          }
+          break;
+        case 'replace':
+          // Matches the only paths that should be handled here: /corpora/{corpusId}/{status || description || title}
+          if (/^\/corpora\/(\d+)\/(status|description|title)$/.test(operation.path)) {
+            let [match, id, valueName] = operation.path.match(/^\/corpora\/(\d+)\/(status|description|title)$/);
+            this.replace(id, valueName, operation.value);
+          }
+          break;
+        default:
+          break;
+      }
+    }
+  }
+}
 CorpusList.options = {
   item: `<tr>
            <td><a class="btn-floating disabled"><i class="material-icons">book</i></a></td>
@@ -84,23 +149,80 @@ CorpusList.options = {
            <td><span class="badge new status" data-badge-caption=""></span></td>
            <td class="right-align">
              <a class="action-button btn-floating red tooltipped waves-effect waves-light" data-action="delete" data-position="top" data-tooltip="Delete"><i class="material-icons">delete</i></a>
-             <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="edit" data-position="top" data-tooltip="Edit"><i class="material-icons">edit</i></a>
              <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="analyse" data-position="top" data-tooltip="Analyse"><i class="material-icons">search</i></a>
+             <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
            </td>
          </tr>`,
-  valueNames: [{data: ['id']}, {name: "status", attr: "data-status"}, 'description', 'title']
+  valueNames: [{data: ['id']}, {name: 'status', attr: 'data-status'}, 'description', 'title']
 };
 
 
 class JobList extends RessourceList {
-  constructor(listElementId, options = {}) {
-    let listElement = document.querySelector(`#${listElementId}`);
+  constructor(listElement, options = {}) {
     super(listElement, {...JobList.options, ...options});
-    nopaque.jobsSubscribers.push(this);
+    this.user.addEventListener('jobsInit', jobs => this.init(jobs));
+    this.user.addEventListener('jobsPatch', patch => this.patch(patch));
+    listElement.addEventListener('click', (event) => {this.onclick(event)});
   }
-}
 
+  onclick(event) {
+    let jobId = event.target.closest('tr').dataset.id;
+    let actionButtonElement = event.target.closest('.action-button');
+    let action = actionButtonElement === null ? 'view' : actionButtonElement.dataset.action;
+    switch (action) {
+      case 'delete':
+        let deleteModalHTML = `<div class="modal">
+                                 <div class="modal-content">
+                                   <h4>Confirm job deletion</h4>
+                                   <p>Do you really want to delete the job <b>${this.user.data.jobs[jobId].title}</b>? All files will be permanently deleted!</p>
+                                 </div>
+                                 <div class="modal-footer">
+                                   <a href="#!" class="btn modal-close waves-effect waves-light">Cancel</a>
+                                   <a class="btn modal-close red waves-effect waves-light" href="${this.user.data.jobs[jobId].url}/delete"><i class="material-icons left">delete</i>Delete</a>
+                                 </div>
+                               </div>`;
+        let deleteModalParentElement = document.querySelector('main');
+        deleteModalParentElement.insertAdjacentHTML('beforeend', deleteModalHTML);
+        let deleteModalElement = deleteModalParentElement.lastChild;
+        let deleteModal = M.Modal.init(deleteModalElement, {onCloseEnd: () => {deleteModal.destroy(); deleteModalElement.remove();}});
+        deleteModal.open();
+        break;
+      case 'view':
+        window.location.href = this.user.data.jobs[jobId].url;
+        break;
+      default:
+        console.error(`Unknown action: "${action}"`);
+        break;
+    }
+  }
 
+  patch(patch) {
+    for (let operation of patch) {
+      switch(operation.op) {
+        case 'add':
+          // Matches the only paths that should be handled here: /jobs/{jobId}
+          if (/^\/jobs\/(\d+)$/.test(operation.path)) {this.add(operation.value);}
+          break;
+        case 'remove':
+          // See case add ;)
+          if (/^\/jobs\/(\d+)$/.test(operation.path)) {
+            let [match, id] = operation.path.match(/^\/jobs\/(\d+)$/);
+            this.remove(jobId);
+          }
+          break;
+        case 'replace':
+          // Matches the only paths that should be handled here: /jobs/{jobId}/{service || status || description || title}
+          if (/^\/jobs\/(\d+)\/(service|status|description|title)$/.test(operation.path)) {
+            let [match, id, valueName] = operation.path.match(/^\/jobs\/(\d+)\/(service|status|description|title)$/);
+            this.replace(id, valueName, operation.value);
+          }
+          break;
+        default:
+          break;
+      }
+    }
+  }
+}
 JobList.options = {
   item: `<tr>
            <td><a class="btn-floating disabled"><i class="material-icons service"></i></a></td>
@@ -111,19 +233,17 @@ JobList.options = {
              <a class="action-button btn-floating tooltipped waves-effect waves-light" data-action="view" data-position="top" data-tooltip="View"><i class="material-icons">send</i></a>
            </td>
          </tr>`,
-  valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: "status", attr: "data-status"}, 'description', 'title']
+  valueNames: [{data: ['id']}, {name: 'service', attr: 'data-service'}, {name: 'status', attr: 'data-status'}, 'description', 'title']
 };
 
 
 class QueryResultList extends RessourceList {
-  constructor(listElementId, options = {}) {
-    let listElement = document.querySelector(`#${listElementId}`);
+  constructor(listElement, options = {}) {
     super(listElement, {...QueryResultList.options, ...options});
-    nopaque.queryResultsSubscribers.push(this);
+    this.user.addEventListener('queryResultsInit', queryResults => this.init(queryResults));
+    this.user.addEventListener('queryResultsPatch', patch => this.init(patch));
   }
 }
-
-
 QueryResultList.options = {
   item: `<tr>
            <td><b class="title"></b><br><i class="description"></i><br></td>
@@ -136,5 +256,3 @@ QueryResultList.options = {
          </tr>`,
   valueNames: [{data: ['id']}, 'corpus_title', 'description', 'query', 'title']
 };
-
-export { CorpusList, JobList, QueryResultList };
diff --git a/web/app/templates/admin/user.html.j2 b/web/app/templates/admin/user.html.j2
index 97e3137aec812255ea64f7d4e7ee8ec0a90a3f67..78351735e5a76f87f1c59385c1df1ad2f8d60bb1 100644
--- a/web/app/templates/admin/user.html.j2
+++ b/web/app/templates/admin/user.html.j2
@@ -36,16 +36,16 @@
       </div>
     </div>
 
-<div class="col s12 l6">
+<div class="col s12 l6" id="corpora" data-user-id="{{ user.id }}">
   <h3>Corpora</h3>
   <div class="card">
-    <div class="card-content" id="corpora">
+    <div class="card-content">
       <div class="input-field">
         <i class="material-icons prefix">search</i>
         <input id="search-corpus" class="search" type="search"></input>
         <label for="search-corpus">Search corpus</label>
       </div>
-      <table class="highlight">
+      <table class="highlight ressource-list">
         <thead>
           <tr>
             <th></th>
@@ -64,16 +64,16 @@
   </div>
 </div>
 
-<div class="col s12 l6">
+<div class="col s12 l6" id="jobs" data-user-id="{{ user.id }}">
   <h3>Jobs</h3>
   <div class="card">
-    <div class="card-content" id="jobs">
+    <div class="card-content">
       <div class="input-field">
         <i class="material-icons prefix">search</i>
         <input id="search-job" class="search" type="search"></input>
         <label for="search-job">Search job</label>
       </div>
-      <table class="highlight">
+      <table class="highlight ressource-list">
         <thead>
           <tr>
             <th><span class="sort" data-sort="service">Service</span></th>
@@ -109,10 +109,9 @@
 
 {% block scripts %}
 {{ super() }}
-<script type="module">
-  import {RessourceList} from '{{ url_for('static', filename='js/nopaque.lists.js') }}';
-  let corpusList = new RessourceList("corpora", nopaque.foreignCorporaSubscribers, "Corpus");
-  let jobList = new RessourceList("jobs", nopaque.foreignJobsSubscribers, "Job");
-  nopaque.socket.emit("foreign_user_data_stream_init", {{ user.id }});
+<script>
+  nopaque.appClient.loadUser({{ user.id }});
+  let corpusList = new CorpusList(document.querySelector('#corpora'));
+  let jobList = new JobList(document.querySelector('#jobs'));
 </script>
 {% endblock scripts %}
diff --git a/web/app/templates/main/dashboard.html.j2 b/web/app/templates/main/dashboard.html.j2
index e838f72ae4a35a2c7e0e9af00b42a4c1d1e80852..8326654a5df33f3d478915f6f2a607dd3c8a6df4 100644
--- a/web/app/templates/main/dashboard.html.j2
+++ b/web/app/templates/main/dashboard.html.j2
@@ -29,7 +29,7 @@
                 <input id="search-corpus" class="search" type="search"></input>
                 <label for="search-corpus">Search corpus</label>
               </div>
-              <table class="highlight">
+              <table class="highlight ressource-list">
                 <thead>
                   <tr>
                     <th></th>
@@ -102,7 +102,7 @@
             <input id="search-job" class="search" type="search"></input>
             <label for="search-job">Search job</label>
           </div>
-          <table class="highlight">
+          <table class="highlight ressource-list">
             <thead>
               <tr>
                 <th><span class="sort" data-sort="service">Service</span></th>
@@ -175,10 +175,9 @@
 
 {% block scripts %}
 {{ super() }}
-<script type="module">
-  import {CorpusList, JobList, QueryResultList} from '../../static/js/nopaque.lists.js';
-  let corpusList = new CorpusList("corpora");
-  let jobList = new JobList("jobs");
-  let queryResultList = new QueryResultList("query-results");
+<script>
+  let corpusList = new CorpusList(document.querySelector('#corpora'));
+  let jobList = new JobList(document.querySelector('#jobs'));
+  let queryResultList = new QueryResultList(document.querySelector('#query-results'));
 </script>
 {% endblock scripts %}
diff --git a/web/app/templates/nopaque.html.j2 b/web/app/templates/nopaque.html.j2
index a54fda33373dfc1bb35dc0d6ab1d207eb598a86d..804e877601a9d127ca8732456bb3ecb71f58ec20 100644
--- a/web/app/templates/nopaque.html.j2
+++ b/web/app/templates/nopaque.html.j2
@@ -244,28 +244,29 @@
 
 {% block scripts %}
 {{ super() }}
+{% if current_user.setting_dark_mode %}
 <script src="{{ url_for('static', filename='js/darkreader.js') }}"></script>
+<script>
+  DarkReader.enable({brightness: 150, contrast: 100, sepia: 0});
+</script>
+{% endif %}
 <script src="{{ url_for('static', filename='js/jsonpatch.min.js') }}"></script>
 <script src="{{ url_for('static', filename='js/list.min.js') }}"></script>
 <script src="{{ url_for('static', filename='js/socket.io.slim.js') }}"></script>
 <script src="{{ url_for('static', filename='js/nopaque.js') }}"></script>
+<script src="{{ url_for('static', filename='js/nopaque.lists.js') }}"></script>
 <script>
-  {% if current_user.setting_dark_mode %}
-  DarkReader.enable({brightness: 150, contrast: 100, sepia: 0});
-  {% endif %}
   // Disable all option elements with no value
-  for (let optionElement of document.querySelectorAll('option[value=""]')) {
-    optionElement.disabled = true;
-  }
+  for (let optionElement of document.querySelectorAll('option[value=""]')) {optionElement.disabled = true;}
   M.AutoInit();
   M.CharacterCounter.init(document.querySelectorAll('input[data-length][type="email"], input[data-length][type="password"], input[data-length][type="text"], textarea[data-length]'));
   M.Dropdown.init(document.querySelectorAll('#nav-more-dropdown-trigger'), {alignment: 'right', constrainWidth: false, coverTrigger: false});
   nopaque.Forms.init();
+  for (let flashedMessage of {{ get_flashed_messages(with_categories=True)|tojson }}) {nopaque.flash(flashedMessage[1], flashedMessage[0]);}
+</script>
+<script>
   {% if current_user.is_authenticated %}
-  nopaque.socket.emit('user_data_stream_init');
+  nopaque.appClient = new AppClient({{ current_user.id }});
   {% endif %}
-  for (let flashedMessage of {{ get_flashed_messages(with_categories=True)|tojson }}) {
-    nopaque.flash(flashedMessage[1], flashedMessage[0]);
-  }
 </script>
 {% endblock scripts %}