× {{alert.msg}} Never ask again
Receive New Tutorials
GET IT FREE

Sharing Data in AngularJS: Be Aware of $watch

– {{showDate(postTime)}}

This AngularJS tutorial is about concise and precise communication of shared state updates from AngularJS services to AngularJS controllers. It warns about race conditions upon AngularJS application bootstrap, and points out advantages of $broadcast over $watch. The topics discussed in this article are supported by minimal working code examples. Finally, this article provides code that can hopefully serve as a best-practice snippet for your own application. The article was originally posted at Jan-Phillip Gehrcke’s blog.

Note: this article has been written with AngularJS version 1.3.X in mind. Future versions of Angular, especially the announced version 2.0, might behave differently.


Introduction to the problem

I have worked with AngularJS for a couple of days now, designing an application that needs to interact with a web service. In this application, I use a small local database (basically a large JavaScript object) that is used by different views in different ways. From time to time, this database object requires to be updated by a remote resource. In the AngularJS ecosystem it seems obvious that such data should be part of an application-wide shared state object and that it needs to be managed by a central entity: an AngularJS service (remember: services in AngularJS can be considered as globally available entities, i.e. they are the perfect choice for communicating between controllers and for sharing state). The two main questions that came to my mind considering this scenario:

  1. How should I handle the automatic initial retrieval of remote data upon application startup?
  2. How should I communicate updates of this piece of shared data to controllers?

The answers to these questions must make sure that the following boundary conditions are fulfilled: controllers need to be informed about all state updates (including the initial one) independently of

  • the application startup time (which is defined by the computing power of the device and the application complexity) and independently of
  • the latency between request and response when querying the remote resource.

An obvious solution (with not-so-obvious issues)

The HTML

Let us get right into code and discuss a possible solution, by means of a small working example. This is the HTML:

<!DOCTYPE html>
<html data-ng-app="testApp">
  <head>
    <script data-require="angular.js@1.3.1" data-semver="1.3.1" src="//code.angularjs.org/1.3.1/angular.js"></script>
  </head>
  <body data-ng-controller="Ctrl">
    Please watch the JavaScript console.<br>
    <button ng-click="buttonclick(false)">updateState(constant)</button>
    <button ng-click="buttonclick(true)">updateState(random)</button>
    <script src="script.js"></script>
  </body>
</html>

It includes the AngularJS framework and custom JavaScript code from script.js. The ng main module is called testApp and the body is subject to the ng controller called Ctrl. There are two buttons whose meaning is explained later.

The service ‘StateService’

So, what do we have in script.js? There is the obligatory line for defining the application’s main module:

var app = angular.module('testApp', []);

And there is the definition of a service for this application:

var UPDATE_STATE_DELAY = 1000;
 
app.factory('StateService', ['$rootScope', '$timeout',
function($rootScope, $timeout) {
 
  console.log('StateService: startup.');
  var service = {state: {data: null}};
 
  service.updateState = function(rnd) {
    console.log("StateService: updateState(). Retrieving data...");
    $timeout(function() {
      console.log("StateService: ...got data, assign it to state.data.");
      if (rnd)
        service.state.data = Math.floor(Math.random()*1000);
      else
        service.state.data = "constantpayload";
    }, UPDATE_STATE_DELAY);
  };
 
  // Update state automatically once upon service (app) startup.
  service.updateState();
 
  return service;
}]);

I have called it 'StateService' because this service should just be responsible for sharing state between controllers. The property service.state.data is what simulates the shared data — this is what controllers are interested in! This piece of data is first initialized with null.

Subsequently, an updateState() method is defined. It simulates delayed retrieval of data from a remote resource via a timeout-controlled async call which eventually results in assignment of “new” data to service.state.data. This method can be called in two ways:

  • One way results in service.state.data being set to a hard-coded string.
  • The other results service.state.data being set to a random number.

The length of the delay after which the pseudo remote data comes in is set to about 1 second, as defined by var UPDATE_STATE_DELAY = 1000.

The service factory (that piece of code shown above) is automatically executed by AngularJS when loading the application. It is important to note that before the service factory returns the service object, service.updateState() is called. That is, when the application bootstraps and this service becomes initialized, it automatically performs one state update. This triggers “the automatic initial retrieval of remote data upon application startup” I talked about in the introduction.

Consequently, about 1 second after this service has been initialized, the service.state.data object is updated with pseudo remote data. Subsequent calls to updateState() can only be triggered externally, as I will show later.

The controller ‘Ctrl’

StateService in place. So far, so good. This is how a controller can look which makes use of it:

app.controller('Ctrl', ['$scope', 'StateService',
function($scope, stateService) {
 
  function useStateData() {
    console.log("Ctrl: useStateData(): " + stateService.state.data);
  }
 
  function init() {
    console.log('Ctrl: init. Install watcher for stateService.state.data.');
    $scope.$watch(
      function() {return stateService.state.data;},
      function(newValue, oldValue) {
        console.log("Ctrl: stateService.state.data watcher: triggered.");
        if (newValue !== oldValue) {
          console.log("Ctrl: stateService.state.data watcher: data changed, use data.");
          useStateData();
        }
        else
          console.log("Ctrl: stateService.state.data watcher: data did not change: " + oldValue);
      }
    );
  }
 
  init();
 
  $scope.buttonclick = function(random) {
    console.log("Ctrl: Call stateService.updateState() due to button click.");
    stateService.updateState(random);
  };
}]);

For being able to communicate state changes from the StateService to the controller, the service is injected into the controller as the stateService object. That just means: we can use this object within the code body of the controller to access service properties, including stateService.state.data.

In the controller, first of all, I define a dummy function called useStateData(). Its sole purpose is to simulate complex usage of the shared state data. In this case, if the function is called, the data is simply logged to the console.

Subsequently, an init() function is defined and called right after that (I could have put that code right into the body of the controller, but further below in the article the call to init() is wrapped with a timeout, and that is why I already separate it here).

Now we come to the essential part: In summary, the basic idea is to have a mechanism applied in the controller that automatically calls useStateData() after stateService.state.data has changed.

For automatic communication of state changes from the service to the controller, AngularJS provides different mechanisms. In very simplistic scenarios we could just bind stateService.state.data to any of the model properties in the controller’s scope and rely on Angular’s “automatic” two-way binding. However, in this article the goal is to discuss more complex scenarios where we need to take absolute control of the state update and where we want to react to a state update in a more general way, i.e. by calling a function in response to the update (here, this is useStateData()).

That is what Angular’s $scope.$watch() is good for. It gets (at least) two arguments. A “watcher function” is defined with the first argument. In this case here, this watcher function just returns the value of stateService.state.data. This watcher function is called in every Angular event loop iteration (upon each call to $digest()). If the value that it watches changes between two iterations, the listener function is called. The listener is defined by the second argument to $scope.$watch(). In our simple example here, the purpose of the listener function is to just use the data, i.e. to call useStateData().

The controller contains some additional code that gives a purpose to the two buttons included in the HTML shown before. One button calls updateState(true), triggering a state update in which the data is set to a random number. The other button calls updateState(false) where the data is set to a hard-coded string (a constant).

Fine, sounds good so far, the controller is ready to respond to state updates. But wait …

Three traps with $scope.$watch()

Run the example shown above via this plunk and watch your JavaScript console. This is the output right after (< 1 s) loading the application:

StateService: startup.
StateService: updateState(). Retrieving data...
Ctrl: init. Install watcher for stateService.state.data.
Ctrl: stateService.state.data watcher: triggered.
Ctrl: stateService.state.data watcher: data did not change: null

trap 1: $watch() listener requires case analysis

Let us go through things in order. First, the service is initialized and triggers updateState(), as planned. We expect a state update about 1 second after that. Next thing in the log is output emitted by the controller code: it installs the watcher via $scope.$watch(). Immediately after that the watcher already calls the listener function. The pseudo remote update still did not happen, so why is that function being called? This is explained in the Angular docs:

After a watcher is registered with the scope, the listener fn is called asynchronously to initialize the watcher. In rare cases, this is undesirable because the listener is called when the result of watchExpression didn’t change. To detect this scenario within the listener fn, you can compare the newVal and oldVal.

Wuah, what? I did not explain this before, but this is the reason why the listener function code shown above requires to have a case analysis. We need to manually compare the old value to the new value via

function(newValue, oldValue) {
  console.log("Ctrl: stateService.state.data watcher: triggered.");
  if (newValue !== oldValue) {
    console.log("Ctrl: stateService.state.data watcher: data changed, use data.");
    useStateData();
  }
  else
    console.log("Ctrl: stateService.state.data watcher: data did not change: " + oldValue);
}

If you prefer to simply rely on the trigger and forget to do the case analysis, you may already have a hard-to-debug issue in your code:

function() {
  console.log("Ctrl: stateService.state.data watcher: triggered, use data.");
  useStateData();
  // Wait, maybe that here was just called due to the watcher init, oops!
}

Okay, looking at the log output above again, indeed, the first invocation of the listener function resulted in “data did not change: null”. That is, newValue !== oldValue was false. I have not put timestamps into the log, but the following lines are the remaining output of the application (they appeared after about 1 second):

StateService: ...got data, assign it to state.data.
Ctrl: stateService.state.data watcher: triggered.
Ctrl: stateService.state.data watcher: data changed, use data.
Ctrl: useStateData(): constantpayload

As expected, the StateService retrieves its pseudo remote data and re-assigns its state.data object. The $timeout service triggers an Angular event loop iteration, i.e. the assignment is wrapped by an Angular-internal call to $digest(). Consequently, the change is observed by Angular and the listener function of the installed watcher gets called. This time, the (annoying) case analysis makes useStateData() being called. It prints the updated data.

Until here, we have found a way to communicate a state change from a service to a controller, via $watch(). Sounds great. However, this method involves potential false-positive calls to the listener function. To properly deal with this awkward situation, a case analysis is required within the very same. This case analysis is, in my opinion, either a mean trap if you forgot to implement it or unnecessarily bloated code. It simply should not be required.

trap 2: $watch() might swallow special updates

Let us proceed with the same minimal working example. The application is initialized. The state service retrieved its initial update from a pseudo remote source and notified the controller about this update. Now, you can go ahead and play with the button “updateState(random)” of the minimal working example. The console log should display something in these lines for each button click:

Ctrl: Call stateService.updateState() due to button click.
StateService: updateState(). Retrieving data...
StateService: ...got data, assign it to state.data.
Ctrl: stateService.state.data watcher: triggered.
Ctrl: stateService.state.data watcher: data changed, use data.
Ctrl: useStateData(): 148

The chain is working: a button click results in a timeout being set. After about 1 second the data property of the state service gets assigned a new (random number) value. The watcher detects the change and immediately calls the listener function which, in turn, calls the useStateData() method of the controller.

Now, please press “updateState(constant)”, two times at least. What is happening? This is the log (after the second click):

Ctrl: Call stateService.updateState() due to button click.
StateService: updateState(). Retrieving data...
StateService: ...got data, assign it to state.data.

The button click is logged. The StateService invokes its update function. After about 1 second, the string “constantpayload” is again assigned to the data property of the state object. As expected, so far. And….? The listener function in the controller does not get called. Never. Why? Because before the update and after the update the watched property, data, was pointing to the same object. In my code example, the same string object (created from one single string literal) is re-assigned to data upon every click on named button. That is, data‘s reference never changes. And, according to the AngularJS docs, the $watch()-internal comparison is done by reference (that is the default, at least). Hence, if I had written

service.state.data = new String("constantpayload");

in the stateService.updateState() function, the listener function would be triggered upon each click on discussed button, because a new string object would be created each time and data‘s reference would change.

Let us reflect. Just a minute ago, in the case discussed before, special $watch() behavior forced us to do a manual case analysis in the listener function in order to decide whether there was a real update or not. Now we found a situation in which we do not even get into the position to manually process an update event in the listener function, because Angular’s $watch() mechanism decided internally that this was not an update. Discussing whether not changing the value during an update can be considered an update or not is a philosophical question. Meaning: it should not be answered for you, this is too much of artificial intelligence. You might want to deal with this question yourself in your controller, e.g. for knowing when the last update occurred, even if the data did not change. If you have hard-coded objects in your application and combine these with $watch(), you might end up with rather complex code paths that you possibly did not expect to even exist. All of this is documented, but it is a trap.

Hence, my opinion is that this behavior of $watch() is too subtle to be considered for concise event transmission.

(At the same time, I appreciate that in many situations developers are not interested in propagating such updates that are no real updates, and are just fine with how $watch() behaves, be it by accident or by strategy).

trap 3: $watch() might seriously affect application performance

This one is really important for architectural decisions. Consider a scenario in which the shared state object is just a “container object” with a rather complex internal structure with many properties that can potentially change during an update. Then, as we have learned before, $watch() cannot simply detect changes in this object. The watched property always points to the container object, i.e. this reference does not change when the internals change. AngularJS provides two solutions to this: $watchCollection() and $watch() with the third argument (objectEquality) set to true. In both cases, the computational complexity of change detection depends on the complexity of the watched object. $watch(watcher, listener, true) performs a thorough analysis of the watched object, it “compares for object equality using angular.equals instead of comparing for reference equality.” The docs warn:

This therefore means that watching complex objects will have adverse memory and performance implications.

You can read more about the intrinsics of $watch() in the “Scope” part of the AngularJS developer guide. In fact, this analysis requires the container object to become deeply inspected for changes. This implicates saving a deep copy of the container object and a comparison of many values. This is costly on its own. But the important thing is: this is executed upon each $digest() round-trip of the framework. That is: often. And definitely upon each user interaction. Consequently, I would say that one should never watch complex objects in such fashion, because the associated complexity usually is not required. In a software project, the complexity of watched objects might grow from release to release, and developers might not be aware of the performance implications, especially in collaborative works. I find that the computational complexity for detecting an update and sending a notification about the very same should ideally never depend on the size of an object, it should just be O(1). Let’s face it: people use $watch() for getting notified, they might forget about its performance implications, and that is why $watch() should be O(1) or throw an error, in my opinion. But this questions the entire dirty-checking approach of Angular, so this is out of scope right now. Anyway, the associated complexity is hidden behind the scenes and will only become visible upon profiling. Just be aware of it.

In the beginning of the article I stated that I want to have a database-like object as part of the shared state. Clearly, a $watch()-based method for automatic change detection is not a good option, as of trap number 3. But also traps number 1 and 2 let me not like $watch() too much. You feel it, we work ourselves more and more into the direction of simple event broadcasts, and we will get to those further down in the article. But before getting there, let us discuss another crucial issue with the architecture shown so far: a race condition.

Race condition: initial remote resource query vs. application startup time

Upon application start, named little database needs to be populated with data from a remote resource. It makes sense to request this data from within the service initialization code, as shown above, via the automatic call to updateState(). Obviously, the point in time when the corresponding response arrives over the wire is not predictable. That is a racer. Let us name him racer A. We do not know how long it takes for him to arrive.

An AngularJS application starts up piece-wise. Various services, controllers and directives need to be initialized. The exact order and timing of actions depends on (at least)

  • the complexity of the application,
  • the order in which things are coded,
  • the way in which Angular is designed to bootstrap itself,
  • the computational performance of the device loading the application, and
  • the load on the device loading the application.

Hence, it is unpredictable at which point in time some controller code is executed which registers a watcher/listener for a certain event. Formally spoken, we can not predict how much time T passes between

  • initial code execution of the shared state service and
  • initial code execution of any given controller consuming this service.

That is racer B. Racer B needs the unknown time T to arrive. Clearly, racer A and B compete. And that is the race condition: depending on the outcome of the race, the status update event might be available before or after certain view controllers register corresponding event listeners.

The code shown so far assumes that T is small compared to the time required for the service to obtain its initial update from the remote resource: the first update event is expected to fly in after the watcher has been installed. Clearly, if that assumption is wrong, the first update event is simply missed by the controller.

Demonstration

I have prepared this plunk for demonstrating the race condition: http://embed.plnkr.co/y52thV.

It contains the same code as shown before, with two small modifications: the pseudo remote resource delay is reduced to half a second, and the controller initialization is artificially delayed by one second. That is, state.data is changed before the watcher is installed via $scope.$watch(). The controller does not automatically become notified about the initial state update.

This race condition and all discussed $watch-related traps are fixed/non-existing in the solution provided in the next section.

A better solution

$broadcast() / $emit() instead of $watch()

Many on-line resources discourage overusing $broadcast / $emit in AngularJS applications. While that may be good advice in principle, I want to use this opportunity to speak in favor of $broadcast. I think that in my described use case this technique is a perfect fit. Compared to the $watch-based solution discussed above, the simple $broadcast / $emit event semantics have clear advantages. Why is that? Because $broadcast allows for cleanly decoupling three processes:

  1. Construction/modification of the shared data.
  2. Update detection.
  3. Event transmission.

These three processes are inseparably intertwined when one uses $watch(). Having them decoupled provides flexibility. This flexibility can be translated into the following advantages:

  1. “Change detection” code is not executed upon each $digest() cycle. It needs to be explicitly invoked and can usually be derived from foreign triggers (such as an AJAX call done callback/promise).
  2. Event transmission is of constant complexity (O(1)). It will always be, even if the “watched object” changes.
  3. There is no artificial intelligence working behind the scenes that re-interprets what a data change might have meant. The situation becomes as simple as possible: one event has one meaning. If that is what is wanted, then the event becomes emitted. Event emission and event absorption both are under precise control of the developer.

I have therefore modified the architecture shown before:

  • After having retrieved data from the remote resource, the service now broadcasts the event state_updated through the $rootScope. This event gets emitted to all scopes, and is therefore visible to all controllers (although in our example there is only one controller).
  • The controller installs a listener for this event and simply calls useStateData() when the event flies in. No case analysis required — we know what this event means, its emission is under our precise control, and we react to it always in the same way.

This is the code:

var UPDATE_STATE_DELAY = 500;
var CONTROLLER_INIT_DELAY = 1000;
var app = angular.module('testApp', []);
 
 
app.factory('StateService', ['$rootScope', '$timeout',
function($rootScope, $timeout) {
 
  console.log('StateService: startup.');
  var service = {state: {data: null}};
 
  service.updateState = function() {
    // Simulate data retrieval from a remote resource: data assignment (and
    // event broadcast) happens some time after service initialization.
    console.log("StateService: updateState(). Retrieving data...");
    $timeout(function() {
      console.log("StateService: ...got data, broadcast state_updated");
      service.state.data = "payload";
      $rootScope.$broadcast('state_updated');
    }, UPDATE_STATE_DELAY);
  };
 
  // Update state automatically once upon service (app) startup.
  service.updateState();
 
  return service;
}]);
 
 
app.controller('Ctrl', ['$scope', '$timeout', 'StateService',
function($scope, $timeout, stateService) {
 
  function useStateData() {
    console.log("Ctrl: useStateData(): " + stateService.state.data);
  }
 
  function init() {
    console.log('Ctrl: init. Install event handler for state_updated');
    // Install event handler, for being responsive to future state updates.
    // Handler is attached to local $scope, so it gets automatically destroyed
    // upon controller destruction.
    $scope.$on('state_updated', function () {
      console.log("Ctrl: state_updated event retrieved. Use data.");
      useStateData();
    });
    // If there have been state updates in the past (between application start
    // and controller initialization), handle the last one of those updates.
    if (stateService.state.data) {
      console.log("Ctrl: init: there is some data already. Use it!");
      useStateData();
    }
  }
 
  // Simulate longish app init time: delay execution of this controller init.  
  $timeout(function() {
    init();
  }, CONTROLLER_INIT_DELAY);
 
  // Provide the user with a method to trigger updateState() via button click.
  $scope.buttonclick = function () {
    console.log("Ctrl: Call stateService.updateState() due to button click.");
    stateService.updateState();
  };
}]);

$broadcast event handlers created in controllers and listening on $rootScope need to be destroyed manually if not needed anymore, otherwise they survive as long as the application lives, possibly resulting in a memory leak. This can be prevented by destroying such event listeners upon controller destruction. As noted in the code right above, this is not necessary when listening on the child scope: Controller destruction triggers destruction of its child scope, which itself triggers destruction of all event handlers. Great.

Strictly spoken, the complexity of calling $broadcast() depends on the number of child scopes existing in the application at the time of event emission. This number usually is not large at all and about constant. Using $emit(), event emission can be made a real O(1) operation. It notifies just the root scope and therefore does not require iterating through the child scopes. However, when doing so, one needs to inject the root scope into controllers, and attach event handlers to it. As stated before, such handlers should be manually removed upon controller destruction. This benchmark shows that for 100 child scopes, $emit() is significantly faster than $broadcast().

Race condition abandoned

The race condition discussed before got abandoned from the last code example, by simply calling useStateData() in the controller if stateService.state.data is not nullright after installing the event handler. Why does this work and doesn’t this introduce even more subtle race conditions? Can’t this make useStateData() being called twice on the same data?

The main reason why that works is that we can make certain assumptions about the execution flow, as discussed in the following paragraph. Let us have a careful look at init() in the controller code:

function init() {
  $scope.$on('state_updated', function () {
    useStateData();
  });
  if (stateService.state.data) {
    useStateData();
  }
}

The first action is that the event handler is installed. The essential insight is: the handler function will for sure not be invoked before init() returns. Why? JavaScript can be considered single-threaded (there is no simultaneous code execution, there is only one (virtual) execution thread). In fact, JavaScript functions are not re-entrant, they rather are atomic execution units. That is, once the execution flow enters init(), it does not leave it until init()‘s end is reached. There is simply no time slice for the registered event handler to be invoked before init() returns. That means: if there have been state updates in the past (before init() was invoked),

  • the event listener is installed after the last update event was emitted by the service,
  • stateService.state.data is not null anymore when init() reaches line 5 (the developer needs to guarantee that no update ever resets that property to null) and, consequently,
  • useStateData() in line 6 becomes invoked.

Any (previous or future) foreign call to StateService.updateState() from elsewhere in the application results — at some point in time — in execution of this function (defined in the service code):

function() {
  service.state.data = "payload";
  $rootScope.$broadcast('state_updated');
}

This itself is an atomic execution unit where data modification and event emission are condensed within a single transaction (they do not go at all or they go together). As of the above considerations, this execution unit is not invoked before the end of the init() function is reached. Consequently, the code in init() guarantees that the two calls to useStateData() (lines 3 & 6) are always separated by an assignment (via the = operator) to service.state.data.

Best-practice MWE

The following piece of code is based on all considerations made above and cleaned from comments and console output:

The index.html

<!DOCTYPE html>
<html data-ng-app="testApp">
  <head>
    <script data-require="angular.js@1.3.1" data-semver="1.3.1" src="//code.angularjs.org/1.3.1/angular.js"></script>
    <script src="script.js"></script>
  </head>
  <body data-ng-controller="Ctrl">
    <button ng-click="buttonclick()">updateState()</button><br><br>
    Initial state (retrieved automatically and displayed about 2
    seconds after application startup): {{initstate}}<br><br>
    Current state (changed upon button click with 0.5 s delay
    (try clicking with high frequency!)): {{curstate}}
  </body>
</html>

The script.js

angular.module('testApp', [])

.factory('StateService', ['$rootScope', '$timeout',
function($rootScope, $timeout) {
  var service = {state: {data: null}};
  
  service.updateState = function() {
    $timeout(function() {
      service.state.data = Math.floor(Math.random()*100000);
      $rootScope.$broadcast('state_updated');
    }, 500);
  };

  service.updateState();
  return service;
}])

.controller('Ctrl', ['$scope', '$timeout', 'StateService',
function($scope, $timeout, stateService) {
  function useStateData() {
    $scope.curstate = stateService.state.data;
  }
  
  function init() {
    $scope.$on('state_updated', function() {useStateData()});
    if (stateService.state.data) {
      $scope.curstate = $scope.initstate = stateService.state.data;
    }
  }
  
  $timeout(function() {init()}, 2000);
  $scope.buttonclick = stateService.updateState;
}]);

Play with it (run it here at Plunker) and feel free to reuse it:

Summary

I hope to have shown to you that in certain cases a $watch()-based solution may result in undesired code behavior, and that using $broadcast()– or $emit()-based communication of state updates might yield simpler and yet more reliable code. Also, please remember that $watch() has the potential to produce a severe performance regression. In the last part of the article I pointed out that one should not accidentally make startup code depend on the difference between application loading time and remote resource query latency. This introduces race conditions which usually are difficult to reproduce and debug.

Thanks for reading, and of course I’d be happy to retrieve some feedback.


 

About the Author

Jan-Philip Gehrcke is a physicist, an IT enthusiast, and an open source software developer. He has a large interest for web technology, especially for the magic behind the scenes: fault tolerance, data streams, network technology, scalable and distributed systems. He likes the art of system design and coding. You can find more of his musings at his blog.




Questions about this tutorial?  Get Live 1:1 help from Angular experts!
RajhaRajesuwari S
RajhaRajesuwari S
5.0
Full Stack PHP / NODE/REACT/ WORDPRESS/SHOPIFY web developer
I am an experienced full stack developer 15 years in the field with consistent knowledge in developing web portals with expertise in all opensource...
Hire this Expert
Fernando Yray
Fernando Yray
5.0
Full-Stack Software Developer with 5+ years of experience
Solution-oriented. Love helping others. Passionate on Software/Web Development. I'm also open for **part-time/full-time job opportunity**. If your...
Hire this Expert
comments powered by Disqus